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.

316 lines
11 KiB

<?php
namespace App\Services\Sms;
use App\Models\AudienceSmsLog;
use App\Models\Competition;
use App\Models\SmsVerification;
use App\Support\PortalLocale;
use App\Support\SmsConfig;
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
{
private string $locale = PortalLocale::ZH;
public function sendLoginCode(
string $mobile,
SmsSender $sender,
string $scene = SmsVerification::SCENE_PARTICIPANT_LOGIN,
?Competition $competition = null,
string $locale = PortalLocale::ZH,
): ParticipantSmsVerificationResult {
$this->locale = PortalLocale::fromRequest($locale);
$lock = Cache::lock(
'sms-send-lock:'.$scene.':'.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),
'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' => [PortalLocale::choose(
$this->locale,
sprintf('请勿频繁发送,请 %d 秒后再试', $seconds),
sprintf('Please wait %d s before requesting another code.', $seconds),
)],
]);
}
private function maskMobile(string $mobile): string
{
if (strlen($mobile) < 11) {
return '***';
}
return substr($mobile, 0, 3).'****'.substr($mobile, -4);
}
}