feat(sms): failover SuperAdmin SMS hosts with sticky success cache

Try montzar/montech/montest when wallet panel calls fail; remember last working base for 7 days. No .env change required.
This commit is contained in:
2026-08-24 17:32:55 +03:30
parent dd9a287f24
commit 61dbaf1f0f
2 changed files with 162 additions and 35 deletions
+159 -34
View File
@@ -3,6 +3,7 @@
namespace App\Services\SmsBilling; namespace App\Services\SmsBilling;
use Illuminate\Http\Client\Response; use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -13,14 +14,27 @@ use Illuminate\Support\Facades\Log;
* Env: SUPERADMIN_SMS_BASE_URL + SUPERADMIN_SMS_SHARED_SECRET * Env: SUPERADMIN_SMS_BASE_URL + SUPERADMIN_SMS_SHARED_SECRET
* Optional: SUPERADMIN_SMS_DOMAIN (else host of APP_URL) * Optional: SUPERADMIN_SMS_DOMAIN (else host of APP_URL)
* *
* Failover: tries SUPERADMIN_SMS_BASE_URL first (if set), then the three
* known SuperAdmin hosts. Last successful base is cached and tried first next time.
*
* Fallback: SUPERADMIN_REMOTE_TOKEN (per-tenant Bearer) if shared secret missing. * Fallback: SUPERADMIN_REMOTE_TOKEN (per-tenant Bearer) if shared secret missing.
*/ */
class SuperAdminSmsClient class SuperAdminSmsClient
{ {
private const SUCCESS_BASE_CACHE_KEY = 'superadmin_sms_success_base';
private const SUCCESS_BASE_CACHE_TTL_SECONDS = 60 * 60 * 24 * 7;
/** @var list<string> */
private const DEFAULT_BASES = [
'https://adm.montzar.ir/api/remote/sms',
'https://adm.montech.ir/api/remote/sms',
'https://adm.montest.ir/api/remote/sms',
];
public function enabled(): bool public function enabled(): bool
{ {
$base = trim((string) config('services.superadmin.sms_base_url', '')); if ($this->baseUrls() === []) {
if ($base === '') {
return false; return false;
} }
@@ -182,40 +196,158 @@ class SuperAdminSmsClient
return null; return null;
} }
try { $bases = $this->orderedBaseUrls();
$pending = Http::withHeaders($this->authHeaders($method, $canonicalPath)) $method = strtoupper($method);
->acceptJson() $lastError = null;
->timeout($timeout);
$url = $this->url($relativeUrl); foreach ($bases as $index => $base) {
$method = strtoupper($method); try {
$pending = Http::withHeaders($this->authHeaders($method, $canonicalPath))
->acceptJson()
->timeout($timeout);
/** @var Response $response */ $url = $this->urlForBase($base, $relativeUrl);
$response = match ($method) {
'GET' => $pending->get($url, $query),
'POST' => $pending->post($url, $body ?? []),
'PUT' => $pending->put($url, $body ?? []),
'PATCH' => $pending->patch($url, $body ?? []),
default => throw new \InvalidArgumentException("Unsupported method {$method}"),
};
if (!$response->successful()) { /** @var Response $response */
Log::warning("SuperAdmin SMS {$label} failed", [ $response = match ($method) {
'status' => $response->status(), 'GET' => $pending->get($url, $query),
'body' => $response->body(), 'POST' => $pending->post($url, $body ?? []),
'PUT' => $pending->put($url, $body ?? []),
'PATCH' => $pending->patch($url, $body ?? []),
default => throw new \InvalidArgumentException("Unsupported method {$method}"),
};
if (!$response->successful()) {
$lastError = 'HTTP '.$response->status();
Log::warning("SuperAdmin SMS {$label} failed", [
'base' => $base,
'candidate' => $index + 1,
'total' => count($bases),
'status' => $response->status(),
'body' => $response->body(),
]);
continue;
}
$data = $response->json();
if (!is_array($data)) {
$lastError = 'invalid JSON';
Log::warning("SuperAdmin SMS {$label} invalid JSON", [
'base' => $base,
]);
continue;
}
$this->rememberSuccessfulBase($base);
return $data;
} catch (\Throwable $e) {
$lastError = $e->getMessage();
Log::warning("SuperAdmin SMS {$label} exception", [
'base' => $base,
'candidate' => $index + 1,
'total' => count($bases),
'error' => $e->getMessage(),
]); ]);
}
}
Log::warning("SuperAdmin SMS {$label} unavailable on all bases", [
'bases' => $bases,
'last_error' => $lastError,
]);
return null;
}
/**
* @return list<string>
*/
private function orderedBaseUrls(): array
{
$bases = $this->baseUrls();
if ($bases === []) {
return [];
}
$sticky = $this->readSuccessfulBase();
if ($sticky === null || !in_array($sticky, $bases, true)) {
return $bases;
}
return array_values(array_unique(array_merge([$sticky], $bases)));
}
/**
* Env base (if set) first, then the three known SuperAdmin hosts.
* No .env change required when SUPERADMIN_SMS_BASE_URL already points at montzar.
*
* @return list<string>
*/
private function baseUrls(): array
{
$out = [];
$fromEnv = trim((string) config('services.superadmin.sms_base_url', ''));
if ($fromEnv !== '') {
foreach (preg_split('/[\s,;]+/', $fromEnv) ?: [] as $piece) {
$normalized = $this->normalizeBaseUrl((string) $piece);
if ($normalized !== '') {
$out[] = $normalized;
}
}
}
foreach (self::DEFAULT_BASES as $default) {
$out[] = $this->normalizeBaseUrl($default);
}
return array_values(array_unique(array_filter($out)));
}
private function normalizeBaseUrl(string $value): string
{
$base = rtrim(trim($value), '/');
if ($base === '') {
return '';
}
// Accept origin-only values like https://adm.montzar.ir
if (!str_contains($base, '/api/remote/sms')) {
$base .= '/api/remote/sms';
}
return rtrim($base, '/');
}
private function urlForBase(string $base, string $path): string
{
return rtrim($base, '/').'/'.ltrim($path, '/');
}
private function rememberSuccessfulBase(string $base): void
{
try {
Cache::put(self::SUCCESS_BASE_CACHE_KEY, $base, self::SUCCESS_BASE_CACHE_TTL_SECONDS);
} catch (\Throwable $e) {
Log::debug('SuperAdmin SMS sticky base cache write failed', [
'error' => $e->getMessage(),
]);
}
}
private function readSuccessfulBase(): ?string
{
try {
$cached = Cache::get(self::SUCCESS_BASE_CACHE_KEY);
if (!is_string($cached) || trim($cached) === '') {
return null; return null;
} }
$data = $response->json(); return $this->normalizeBaseUrl($cached);
return is_array($data) ? $data : null;
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::warning("SuperAdmin SMS {$label} exception", [
'error' => $e->getMessage(),
]);
return null; return null;
} }
} }
@@ -280,11 +412,4 @@ class SuperAdminSmsClient
return $host; return $host;
} }
private function url(string $path): string
{
$base = rtrim((string) config('services.superadmin.sms_base_url', ''), '/');
return $base.'/'.ltrim($path, '/');
}
} }
+3 -1
View File
@@ -42,7 +42,9 @@ return [
], ],
/* /*
| SuperAdmin SMS wallet (source of truth when both env vars are set). | SuperAdmin SMS wallet (source of truth when secret/token is set).
| SUPERADMIN_SMS_BASE_URL is optional preferred host; client also fails over to
| adm.montzar.ir / adm.montech.ir / adm.montest.ir automatically (no .env change needed).
| Example: SUPERADMIN_SMS_BASE_URL=https://adm.montzar.ir/api/remote/sms | Example: SUPERADMIN_SMS_BASE_URL=https://adm.montzar.ir/api/remote/sms
*/ */
'superadmin' => [ 'superadmin' => [