get()) { Log::warning('sms.send lock_contended', [ 'mobile_mask' => $this->maskMobile($mobile), 'scene' => $scene, ]); $this->throwTooFrequent((int) config('sms.resend_interval_seconds', 60)); } try { return $this->sendWithinLock($mobile, $sender, $scene, $competition); } finally { $lock->release(); } } private function sendWithinLock( string $mobile, SmsSender $sender, string $scene, ?Competition $competition, ): ParticipantSmsVerificationResult { $resendSec = (int) config('sms.resend_interval_seconds', 60); $rateKey = 'sms-send:'.$scene.':'.hash('sha256', $mobile); if (RateLimiter::tooManyAttempts($rateKey, 1)) { $availableIn = max(1, RateLimiter::availableIn($rateKey)); Log::warning('sms.send rate_limited', [ 'mobile_mask' => $this->maskMobile($mobile), 'scene' => $scene, 'rate_limiter_available_in' => $availableIn, 'resend_interval_seconds' => $resendSec, ]); $this->throwTooFrequent($availableIn); } $lastAttempt = SmsVerification::query() ->where('scene', $scene) ->where('mobile', $mobile) ->latest('id') ->first(); if ($lastAttempt?->created_at !== null && $lastAttempt->created_at->gt(now()->subSeconds($resendSec))) { $elapsed = (int) $lastAttempt->created_at->diffInSeconds(now()); $availableIn = max(1, $resendSec - $elapsed); Log::warning('sms.send rate_limited', [ 'mobile_mask' => $this->maskMobile($mobile), 'scene' => $scene, 'last_attempt_id' => $lastAttempt->id, 'last_attempt_status' => $lastAttempt->status, 'last_attempt_at' => $lastAttempt->created_at?->toDateTimeString(), 'resend_interval_seconds' => $resendSec, 'rate_limiter_available_in' => $availableIn, ]); $this->throwTooFrequent($availableIn); } RateLimiter::hit($rateKey, $resendSec); $code = (string) random_int(100000, 999999); $ttlSeconds = (int) config('sms.code_ttl_seconds', 300); $ttlMinutes = (string) (int) ceil($ttlSeconds / 60); Log::info('sms.send branch', [ 'mobile_mask' => $this->maskMobile($mobile), 'scene' => $scene, 'sms_enabled' => (bool) config('sms.enabled'), ]); $verification = SmsVerification::query()->create([ 'scene' => $scene, 'mobile' => $mobile, 'code' => $code, 'provider' => SmsConfig::activeProvider(), 'status' => SmsVerification::STATUS_PENDING, 'template_id' => SmsConfig::activeLoginTemplateId(), 'sign_name' => SmsConfig::activeSignName(), 'request_payload_json' => [ 'phone_number' => $this->maskMobile($mobile), 'template_id' => SmsConfig::activeLoginTemplateId(), 'sign_name' => SmsConfig::activeSignName(), '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(), ]); $this->persistAudienceSmsLog( $competition, $scene, $mobile, $code, $ttlMinutes, $verification, SmsSendResult::failure( $verification->provider, null, 'SENDER_EXCEPTION', $e->getMessage(), ['error' => 'sender_exception'] ) ); return new ParticipantSmsVerificationResult(false, $verification->refresh(), '发送失败'); } 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, ]); $this->persistAudienceSmsLog( $competition, $scene, $mobile, $code, $ttlMinutes, $verification->refresh(), $result, ); return new ParticipantSmsVerificationResult( false, $verification->refresh(), $result->providerCode === 'CONFIG_INCOMPLETE' ? '短信服务配置未完成' : '发送失败' ); } DB::transaction(function () use ($verification, $result, $mobile, $scene) { $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, $scene); }); Log::info('sms.send sent', [ 'mobile_mask' => $this->maskMobile($mobile), 'scene' => $scene, 'verification_id' => $verification->id, 'provider' => $result->provider, 'provider_request_id' => $result->requestId, 'provider_code' => $result->providerCode, ]); $this->persistAudienceSmsLog( $competition, $scene, $mobile, $code, $ttlMinutes, $verification->refresh(), $result, ); return new ParticipantSmsVerificationResult( true, $verification->refresh(), '发送成功', // 临时:联调展示验证码;正式环境勿开 SMS_EXPOSE_DEBUG_CODE (bool) config('sms.expose_debug_code') ? $code : null ); } private function persistAudienceSmsLog( ?Competition $competition, string $scene, string $mobile, string $code, string $ttlMinutes, SmsVerification $verification, SmsSendResult $result, ): void { if ($competition === null || ! (bool) config('sms.enabled')) { return; } $logScene = $scene === SmsVerification::SCENE_AUDIENCE_LOGIN ? AudienceSmsLog::SCENE_AUDIENCE_LOGIN : AudienceSmsLog::SCENE_PARTICIPANT_LOGIN; $vars = [ 'code' => $code, 'ttl' => $ttlMinutes, ]; $content = (string) config('sms.submail.login_content'); foreach ($vars as $key => $value) { $content = str_replace('{'.$key.'}', $value, $content); } $fee = is_array($result->response) && isset($result->response['fee']) && is_numeric($result->response['fee']) ? (int) $result->response['fee'] : null; try { AudienceSmsLog::query()->create([ 'competition_id' => $competition->id, 'audience_registration_id' => null, 'scene' => $logScene, 'mobile' => $mobile, 'template_id' => $verification->template_id, 'content' => $content, 'vars_json' => $vars, 'send_id' => $result->requestId, 'status' => $result->success ? AudienceSmsLog::STATUS_PENDING : AudienceSmsLog::STATUS_FAILED, 'fee' => $fee, 'dropped_reason' => $result->success ? null : $result->providerMessage, 'sent_at' => now(), ]); } catch (\Throwable $e) { Log::error('sms.audience_log persist_failed', [ 'mobile_mask' => $this->maskMobile($mobile), 'competition_id' => $competition->id, 'scene' => $logScene, 'error' => $e->getMessage(), ]); } } private function expirePreviousVerifications(string $mobile, int $currentId, string $scene): void { SmsVerification::query() ->where('scene', $scene) ->where('mobile', $mobile) ->where('id', '<>', $currentId) ->whereIn('status', [ SmsVerification::STATUS_PENDING, SmsVerification::STATUS_SENT, ]) ->update(['status' => SmsVerification::STATUS_EXPIRED]); } private function throwTooFrequent(int $retryAfterSeconds): never { $seconds = max(1, $retryAfterSeconds); throw ValidationException::withMessages([ 'mobile' => [sprintf('请勿频繁发送,请 %d 秒后再试', $seconds)], ]); } private function maskMobile(string $mobile): string { if (strlen($mobile) < 11) { return '***'; } return substr($mobile, 0, 3).'****'.substr($mobile, -4); } }