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.
289 lines
9.8 KiB
289 lines
9.8 KiB
<?php
|
|
|
|
namespace App\Services\Sms;
|
|
|
|
use App\Models\AudienceSmsLog;
|
|
use App\Models\Competition;
|
|
use App\Models\SmsVerification;
|
|
use App\Support\BizDateTime;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class AudienceSmsLogService
|
|
{
|
|
public const LOG_SYNC_CACHE_KEY = 'sms.submail.log_sync_at';
|
|
|
|
public function __construct(
|
|
private readonly SubmailSmsClient $client,
|
|
private readonly AudienceRegistrationSmsNotifier $notifier,
|
|
private readonly ParticipantSmsVerificationService $verificationService,
|
|
private readonly SmsSender $smsSender,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
public function applySubhook(array $payload): void
|
|
{
|
|
$events = $payload['events'] ?? null;
|
|
if (is_string($events)) {
|
|
$trimmed = trim($events);
|
|
if (str_starts_with($trimmed, '[')) {
|
|
$decoded = json_decode($trimmed, true);
|
|
if (is_array($decoded)) {
|
|
$this->applySubhookRows($decoded);
|
|
|
|
return;
|
|
}
|
|
}
|
|
} elseif (is_array($events) && array_is_list($events)) {
|
|
$this->applySubhookRows($events);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->applySubhookEvent($payload);
|
|
}
|
|
|
|
/**
|
|
* @param list<mixed> $rows
|
|
*/
|
|
private function applySubhookRows(array $rows): void
|
|
{
|
|
foreach ($rows as $row) {
|
|
if (is_array($row)) {
|
|
$this->applySubhookEvent($row);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function verifySubhookSignature(string $token, string $signature): bool
|
|
{
|
|
$key = trim((string) config('sms.submail.subhook_key'));
|
|
if ($key === '') {
|
|
return true;
|
|
}
|
|
if ($token === '' || $signature === '') {
|
|
return false;
|
|
}
|
|
|
|
return hash_equals(md5($token.$key), $signature);
|
|
}
|
|
|
|
/**
|
|
* @return array{synced: int, skipped: bool}
|
|
*/
|
|
public function syncFromLogApi(Competition $competition, bool $force = false): array
|
|
{
|
|
if (trim((string) config('sms.submail.app_id')) === '' || trim((string) config('sms.submail.app_key')) === '') {
|
|
return ['synced' => 0, 'skipped' => true];
|
|
}
|
|
|
|
$cacheKey = self::LOG_SYNC_CACHE_KEY.'.'.$competition->id;
|
|
$last = Cache::get($cacheKey);
|
|
if (! $force && is_numeric($last) && (time() - (int) $last) < 60) {
|
|
return ['synced' => 0, 'skipped' => true];
|
|
}
|
|
|
|
$pending = AudienceSmsLog::query()
|
|
->where('competition_id', $competition->id)
|
|
->whereIn('status', [AudienceSmsLog::STATUS_PENDING, AudienceSmsLog::STATUS_SENDING])
|
|
->whereNotNull('send_id')
|
|
->orderByDesc('id')
|
|
->limit(50)
|
|
->get();
|
|
|
|
if ($pending->isEmpty()) {
|
|
Cache::put($cacheKey, time(), 120);
|
|
|
|
return ['synced' => 0, 'skipped' => false];
|
|
}
|
|
|
|
$synced = 0;
|
|
try {
|
|
$oldest = $pending->min('sent_at');
|
|
$start = $oldest instanceof \Carbon\CarbonInterface
|
|
? $oldest->copy()->subMinutes(5)->getTimestamp()
|
|
: now()->subDay()->getTimestamp();
|
|
$json = $this->client->fetchLog([
|
|
'start_date' => $start,
|
|
'end_date' => now()->getTimestamp(),
|
|
'rows' => 50,
|
|
]);
|
|
Cache::put($cacheKey, time(), 120);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('sms.submail.log_sync_failed', ['error' => $e->getMessage()]);
|
|
|
|
return ['synced' => 0, 'skipped' => false];
|
|
}
|
|
|
|
$rows = is_array($json['data'] ?? null) ? $json['data'] : [];
|
|
$bySendId = [];
|
|
foreach ($rows as $row) {
|
|
if (! is_array($row)) {
|
|
continue;
|
|
}
|
|
$sendId = (string) ($row['sendID'] ?? $row['send_id'] ?? '');
|
|
if ($sendId !== '') {
|
|
$bySendId[$sendId] = $row;
|
|
}
|
|
}
|
|
|
|
foreach ($pending as $log) {
|
|
$row = $bySendId[(string) $log->send_id] ?? null;
|
|
if (! is_array($row)) {
|
|
continue;
|
|
}
|
|
$this->applyLogApiRow($log, $row);
|
|
$synced++;
|
|
}
|
|
|
|
return ['synced' => $synced, 'skipped' => false];
|
|
}
|
|
|
|
public function resend(Competition $competition, AudienceSmsLog $log): AudienceSmsLog
|
|
{
|
|
if ((int) $log->competition_id !== (int) $competition->id) {
|
|
abort(404);
|
|
}
|
|
if (! $log->canResend()) {
|
|
throw ValidationException::withMessages([
|
|
'id' => ['仅发送失败的短信可以重发'],
|
|
]);
|
|
}
|
|
if ($log->isLoginScene()) {
|
|
$smsScene = $log->scene === AudienceSmsLog::SCENE_AUDIENCE_LOGIN
|
|
? SmsVerification::SCENE_AUDIENCE_LOGIN
|
|
: SmsVerification::SCENE_PARTICIPANT_LOGIN;
|
|
$this->verificationService->sendLoginCode($log->mobile, $this->smsSender, $smsScene, $competition);
|
|
$created = AudienceSmsLog::query()
|
|
->where('competition_id', $competition->id)
|
|
->where('mobile', $log->mobile)
|
|
->where('scene', $log->scene)
|
|
->where('id', '>', $log->id)
|
|
->orderByDesc('id')
|
|
->first();
|
|
if ($created === null) {
|
|
throw ValidationException::withMessages([
|
|
'id' => ['短信未发送,请检查短信服务配置'],
|
|
]);
|
|
}
|
|
|
|
return $created;
|
|
}
|
|
|
|
$registration = $log->registration;
|
|
if ($registration === null) {
|
|
throw ValidationException::withMessages([
|
|
'id' => ['原报名记录已不存在,无法重发'],
|
|
]);
|
|
}
|
|
|
|
$created = $log->scene === AudienceSmsLog::SCENE_APPROVED
|
|
? $this->notifier->notifyApproved($registration)
|
|
: $this->notifier->notifyPreRegister($registration);
|
|
|
|
if ($created === null) {
|
|
throw ValidationException::withMessages([
|
|
'id' => ['短信未发送,请检查短信服务配置'],
|
|
]);
|
|
}
|
|
|
|
return $created;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
private function applySubhookEvent(array $payload): void
|
|
{
|
|
$event = trim((string) ($payload['events'] ?? ''));
|
|
if (! in_array($event, ['request', 'sending', 'delivered', 'dropped'], true)) {
|
|
return;
|
|
}
|
|
|
|
$sendId = trim((string) ($payload['send_id'] ?? ''));
|
|
if ($sendId === '') {
|
|
return;
|
|
}
|
|
|
|
/** @var AudienceSmsLog|null $log */
|
|
$log = AudienceSmsLog::query()->where('send_id', $sendId)->first();
|
|
if ($log === null) {
|
|
return;
|
|
}
|
|
|
|
$timestamp = $this->parseTimestamp($payload['timestamp'] ?? null);
|
|
if ($event === 'request' || $event === 'sending') {
|
|
if ($log->isFailed() || $log->isTerminalSuccess()) {
|
|
return;
|
|
}
|
|
$log->status = $event === 'sending' ? AudienceSmsLog::STATUS_SENDING : AudienceSmsLog::STATUS_PENDING;
|
|
if (is_string($payload['content'] ?? null) && trim((string) $payload['content']) !== '') {
|
|
$log->content = trim((string) $payload['content']);
|
|
}
|
|
} elseif ($event === 'delivered') {
|
|
$log->status = AudienceSmsLog::STATUS_DELIVERED;
|
|
$log->delivered_at = $timestamp ?? now();
|
|
$log->dropped_reason = null;
|
|
$log->report_state = 'DELIVRD';
|
|
} else {
|
|
$log->status = AudienceSmsLog::STATUS_DROPPED;
|
|
$log->report_state = trim((string) ($payload['report'] ?? '')) ?: $log->report_state;
|
|
$log->dropped_reason = trim((string) ($payload['report_desc'] ?? $payload['report'] ?? '')) ?: $log->dropped_reason;
|
|
$log->delivered_at = $timestamp;
|
|
}
|
|
$log->last_synced_at = now();
|
|
$log->save();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
*/
|
|
private function applyLogApiRow(AudienceSmsLog $log, array $row): void
|
|
{
|
|
$status = strtolower(trim((string) ($row['status'] ?? '')));
|
|
if ($status === 'delivered') {
|
|
$log->status = AudienceSmsLog::STATUS_DELIVERED;
|
|
$log->report_state = (string) ($row['report_state'] ?? 'DELIVRD');
|
|
$log->dropped_reason = null;
|
|
$log->delivered_at = $this->parseTimestamp($row['report_at'] ?? null) ?? $log->delivered_at;
|
|
} elseif ($status === 'dropped') {
|
|
$log->status = AudienceSmsLog::STATUS_DROPPED;
|
|
$log->report_state = (string) ($row['report_state'] ?? $log->report_state);
|
|
$reason = trim((string) ($row['dropped_reason'] ?? ''));
|
|
$log->dropped_reason = $reason !== '' ? $reason : $log->dropped_reason;
|
|
$log->delivered_at = $this->parseTimestamp($row['report_at'] ?? null);
|
|
} elseif ($status === 'pending' && ! $log->isFailed() && ! $log->isTerminalSuccess()) {
|
|
$log->status = AudienceSmsLog::STATUS_PENDING;
|
|
}
|
|
|
|
if (isset($row['fee']) && is_numeric($row['fee'])) {
|
|
$log->fee = (int) $row['fee'];
|
|
}
|
|
$content = trim((string) ($row['sms_content'] ?? ''));
|
|
if ($content !== '') {
|
|
$log->content = $content;
|
|
}
|
|
$log->last_synced_at = now();
|
|
$log->save();
|
|
}
|
|
|
|
private function parseTimestamp(mixed $value): ?\Carbon\Carbon
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
if (is_numeric($value)) {
|
|
return \Carbon\Carbon::createFromTimestamp((int) $value);
|
|
}
|
|
try {
|
|
return \Carbon\Carbon::parse((string) $value, BizDateTime::TIMEZONE)->utc();
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|