You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

200 lines
7.6 KiB

<?php
namespace App\Services\Sms;
use App\Models\SmsVerification;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
class ParticipantSmsVerificationService
{
public function sendLoginCode(string $mobile, SmsSender $sender): ParticipantSmsVerificationResult
{
$lock = Cache::lock(
'sms-send-lock:'.hash('sha256', $mobile),
max(30, (int) config('sms.timeout', 5) + 10)
);
if (! $lock->get()) {
Log::warning('sms.send lock_contended', [
'mobile_mask' => $this->maskMobile($mobile),
]);
throw ValidationException::withMessages([
'mobile' => ['请勿频繁发送'],
]);
}
try {
return $this->sendWithinLock($mobile, $sender);
} finally {
$lock->release();
}
}
private function sendWithinLock(string $mobile, SmsSender $sender): ParticipantSmsVerificationResult
{
$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 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,
]);
return new ParticipantSmsVerificationResult(
false,
$verification->refresh(),
$result->providerCode === 'CONFIG_INCOMPLETE' ? '短信服务配置未完成' : '发送失败'
);
}
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,
]);
return new ParticipantSmsVerificationResult(
true,
$verification->refresh(),
'发送成功',
// 临时:联调展示验证码;正式环境勿开 SMS_EXPOSE_DEBUG_CODE
(bool) config('sms.expose_debug_code') ? $code : null
);
}
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]);
}
private function maskMobile(string $mobile): string
{
if (strlen($mobile) < 11) {
return '***';
}
return substr($mobile, 0, 3).'****'.substr($mobile, -4);
}
}