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

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