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
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);
|
|
}
|
|
}
|