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), ]); $resendSec = (int) config('sms.resend_interval_seconds', 60); $rateKey = 'sms-send:'.hash('sha256', $mobile); if (RateLimiter::tooManyAttempts($rateKey, 1)) { Log::warning('sms.send rate_limited', [ 'mobile_mask' => $this->maskMobile($mobile), 'rate_limiter_available_in' => RateLimiter::availableIn($rateKey), 'resend_interval_seconds' => $resendSec, ]); throw ValidationException::withMessages([ 'mobile' => ['请勿频繁发送'], ]); } $lastAttempt = SmsVerification::query() ->where('scene', SmsVerification::SCENE_PARTICIPANT_LOGIN) ->where('mobile', $mobile) ->latest('id') ->first(); if ($lastAttempt?->created_at !== null && $lastAttempt->created_at->gt(now()->subSeconds($resendSec))) { Log::warning('sms.send rate_limited', [ 'mobile_mask' => $this->maskMobile($mobile), 'last_attempt_id' => $lastAttempt->id, 'last_attempt_status' => $lastAttempt->status, 'last_attempt_at' => $lastAttempt->created_at?->toDateTimeString(), 'resend_interval_seconds' => $resendSec, ]); throw ValidationException::withMessages([ 'mobile' => ['请勿频繁发送'], ]); } RateLimiter::hit($rateKey, $resendSec); $code = (string) random_int(100000, 999999); $smsEnabled = (bool) config('sms.enabled'); $ttlSeconds = (int) config('sms.code_ttl_seconds', 300); $ttlMinutes = (string) (int) ceil($ttlSeconds / 60); Log::info('sms.send branch', [ 'mobile_mask' => $this->maskMobile($mobile), 'sms_enabled' => (bool) config('sms.enabled'), ]); $verification = SmsVerification::query()->create([ 'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN, 'mobile' => $mobile, 'code' => $code, 'provider' => $smsEnabled ? SmsVerification::PROVIDER_TENCENTCLOUD : SmsVerification::PROVIDER_DISABLED, 'status' => SmsVerification::STATUS_PENDING, 'template_id' => (string) config('sms.tencentcloud.template_id'), 'sign_name' => (string) config('sms.tencentcloud.sign_name'), 'request_payload_json' => [ 'phone_number' => $this->maskMobile($mobile), 'template_id' => (string) config('sms.tencentcloud.template_id'), 'sign_name' => (string) config('sms.tencentcloud.sign_name'), 'template_param_count' => (int) config('sms.tencentcloud.template_param_count', 2), 'ttl_minutes' => $ttlMinutes, ], 'expires_at' => now()->addSeconds($ttlSeconds), ]); try { $result = $sender->sendVerification($verification); } catch (\Throwable $e) { $verification->forceFill([ 'status' => SmsVerification::STATUS_FAILED, 'failed_at' => now(), 'provider_code' => 'SENDER_EXCEPTION', 'provider_message' => $e->getMessage(), 'response_json' => ['error' => 'sender_exception'], ])->save(); Log::error('sms.send sender_exception', [ 'mobile_mask' => $this->maskMobile($mobile), 'verification_id' => $verification->id, 'error' => $e->getMessage(), ]); return response()->json([ 'message' => '发送失败', ], 503); } if (! $result->success) { $verification->forceFill([ 'status' => SmsVerification::STATUS_FAILED, 'failed_at' => now(), 'provider_request_id' => $result->requestId, 'provider_code' => $result->providerCode, 'provider_message' => $result->providerMessage, 'response_json' => $result->response, ])->save(); Log::error('sms.send failed', [ 'mobile_mask' => $this->maskMobile($mobile), 'verification_id' => $verification->id, 'provider' => $result->provider, 'provider_request_id' => $result->requestId, 'provider_code' => $result->providerCode, 'provider_message' => $result->providerMessage, ]); return response()->json([ 'message' => $result->providerCode === 'CONFIG_INCOMPLETE' ? '短信服务配置未完成' : '发送失败', ], 503); } DB::transaction(function () use ($verification, $result, $mobile) { $verification->forceFill([ 'status' => SmsVerification::STATUS_SENT, 'sent_at' => now(), 'provider_request_id' => $result->requestId, 'provider_code' => $result->providerCode, 'provider_message' => $result->providerMessage, 'response_json' => $result->response, ])->save(); $this->expirePreviousVerifications($mobile, $verification->id); }); Log::info('sms.send sent', [ 'mobile_mask' => $this->maskMobile($mobile), 'verification_id' => $verification->id, 'provider' => $result->provider, 'provider_request_id' => $result->requestId, 'provider_code' => $result->providerCode, ]); $payload = [ 'message' => '发送成功', ]; if (! $smsEnabled && app()->environment('local', 'testing')) { $payload['debug_code'] = $code; } return response()->json($payload); } 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'], ]); $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] ); Application::query()->firstOrCreate( [ 'user_id' => $user->id, 'competition_id' => $competition->id, ], ['status' => 'draft'] ); $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, ], ]); } 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]); } }