validate([ 'mobile' => ['required', 'regex:/^1[3-9]\d{9}$/'], 'portal' => ['sometimes', 'nullable', 'in:participant,audience'], ]); $mobile = $data['mobile']; $scene = SmsVerification::loginSceneFromPortal( isset($data['portal']) && is_string($data['portal']) ? $data['portal'] : null ); Log::info('sms.send hit', [ 'mobile_mask' => $this->maskMobile($mobile), 'scene' => $scene, 'ip' => $request->ip(), 'user_agent' => substr((string) $request->userAgent(), 0, 200), ]); $result = $service->sendLoginCode($mobile, $sender, $scene); return response()->json($result->toHttpPayload(), $result->success ? 200 : 503); } public function login(Request $request): JsonResponse { $data = $request->validate([ 'mobile' => ['required', 'regex:/^1[3-9]\d{9}$/'], 'code' => ['required', 'string', 'max:64'], 'competition_slug' => ['required', 'string', 'max:64'], 'lang' => ['sometimes', 'nullable', 'string', 'max:16'], ]); $data['public_source_code'] = is_string($request->input('public_source_code')) ? (string) $request->input('public_source_code') : ''; $verification = SmsVerification::query() ->where('scene', SmsVerification::SCENE_PARTICIPANT_LOGIN) ->where('mobile', $data['mobile']) ->where('status', SmsVerification::STATUS_SENT) ->where('expires_at', '>', now()) ->latest('id') ->first(); if ($verification === null || $verification->code !== $data['code']) { throw ValidationException::withMessages([ 'code' => ['验证码无效或已过期'], ]); } $signupLocale = PortalLocale::ZH; $signupLocaleLocked = false; $user = DB::transaction(function () use ($data, $verification, &$signupLocale, &$signupLocaleLocked) { $verification = SmsVerification::query() ->whereKey($verification->id) ->where('scene', SmsVerification::SCENE_PARTICIPANT_LOGIN) ->where('mobile', $data['mobile']) ->where('status', SmsVerification::STATUS_SENT) ->where('expires_at', '>', now()) ->lockForUpdate() ->first(); if ($verification === null || $verification->code !== $data['code']) { throw ValidationException::withMessages([ 'code' => ['验证码无效或已过期'], ]); } $competition = Competition::query() ->where('slug', $data['competition_slug']) ->where('published', true) ->first(); if ($competition === null) { throw ValidationException::withMessages([ 'competition_slug' => ['赛事不存在或未发布'], ]); } $user = User::query()->firstOrCreate( ['mobile' => $data['mobile']], ['name' => null, 'email' => null, 'password' => null] ); if ($user->wasRecentlyCreated) { $this->attributePublicSource($user, (string) ($data['public_source_code'] ?? '')); } $requested = PortalLocale::fromRequest(isset($data['lang']) ? (string) $data['lang'] : null); $application = Application::firstOrCreateDraft($user->id, $competition->id); $locked = $application->isSubmitted(); $seed = $requested; if ($locked && \Illuminate\Support\Facades\Schema::hasColumn($application->getTable(), 'signup_locale') && PortalLocale::isValid((string) $application->signup_locale)) { $seed = (string) $application->signup_locale; } $signupLocale = CompetitionPortalLocale::resolveForLogin( (int) $user->id, (int) $competition->id, PortalLocale::PORTAL_PARTICIPANT, $seed, $locked ); $signupLocaleLocked = $locked; if (! $locked && \Illuminate\Support\Facades\Schema::hasColumn($application->getTable(), 'signup_locale')) { $application->forceFill(['signup_locale' => $signupLocale])->save(); } $verification->forceFill([ 'status' => SmsVerification::STATUS_USED, 'used_at' => now(), ])->save(); $this->expirePreviousVerifications($data['mobile'], $verification->id); return $user; }); $user->tokens()->delete(); $token = $user->createToken('web')->plainTextToken; return response()->json([ 'token' => $token, 'token_type' => 'Bearer', 'signup_locale' => $signupLocale, 'signup_locale_locked' => $signupLocaleLocked, 'user' => [ 'id' => $user->id, 'mobile' => $user->mobile, 'name' => $user->name, 'email' => $user->email, 'company' => $user->company, ], ]); } private function attributePublicSource(User $user, string $sourceCode): void { $sourceCode = trim($sourceCode); if (! preg_match('/^[A-Za-z0-9_-]{2,64}$/', $sourceCode)) { return; } $channel = PublicSourceChannel::query() ->where('source_code', $sourceCode) ->where('status', PublicSourceChannel::STATUS_ENABLED) ->first(); if ($channel === null) { return; } $user->forceFill([ 'public_source_channel_id' => $channel->id, 'public_source_code' => $channel->source_code, 'public_source_attributed_at' => now(), ])->save(); } private function expirePreviousVerifications(string $mobile, int $currentId): void { SmsVerification::query() ->where('scene', SmsVerification::SCENE_PARTICIPANT_LOGIN) ->where('mobile', $mobile) ->where('id', '<>', $currentId) ->whereIn('status', [ SmsVerification::STATUS_PENDING, SmsVerification::STATUS_SENT, ]) ->update(['status' => SmsVerification::STATUS_EXPIRED]); } }