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