validate([ 'mobile' => ['required', 'regex:/^1[3-9]\d{9}$/'], ]); $mobile = $data['mobile']; Log::info('sms.send hit', [ 'mobile_mask' => $this->maskMobile($mobile), 'ip' => $request->ip(), 'user_agent' => substr((string) $request->userAgent(), 0, 200), ]); $result = $service->sendLoginCode($mobile, $sender); 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'], ]); $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' => ['验证码无效或已过期'], ]); } $user = DB::transaction(function () use ($data, $verification) { $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'] ?? '')); } Application::firstOrCreateDraft($user->id, $competition->id); $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', '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]); } }