Files
boursecu_server/app/Services/SmsBilling/SuperAdminSmsClient.php
T

535 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\SmsBilling;
use App\Http\Controllers\Controller;
use Illuminate\Http\Client\Pool;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* SuperAdmin SMS wallet client (status / consume / panel sync).
*
* Preferred auth: shared HMAC + domain (one secret for all sold versions).
* Env: SUPERADMIN_SMS_BASE_URL + SUPERADMIN_SMS_SHARED_SECRET
* 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.
*/
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
{
if ($this->baseUrls() === []) {
return false;
}
return $this->sharedSecret() !== '' || $this->bearerToken() !== '';
}
/**
* @return array{
* allowed: bool,
* blocked: bool,
* reason: ?string,
* balanceRials: int,
* pricePerPartRials: int,
* estimatedCostRials: int,
* balanceLevel: string
* }|null
*/
public function status(int $parts = 1, bool $isAlert = false): ?array
{
$data = $this->requestJson('GET', '/api/remote/sms/status', 'status', [
'parts' => max(1, $parts),
'isAlert' => $isAlert ? '1' : '0',
], null, 4, 'status');
if ($data === null) {
return null;
}
return [
'allowed' => (bool) ($data['allowed'] ?? false),
'blocked' => (bool) ($data['blocked'] ?? false),
'reason' => isset($data['reason']) ? (string) $data['reason'] : null,
'balanceRials' => (int) ($data['balanceRials'] ?? 0),
'pricePerPartRials' => (int) ($data['pricePerPartRials'] ?? 0),
'estimatedCostRials' => (int) ($data['estimatedCostRials'] ?? 0),
'balanceLevel' => (string) ($data['balanceLevel'] ?? 'unknown'),
];
}
/**
* @return array{ok: bool, balanceRials?: int, costRials?: int}|null
*/
public function consume(
string $receiver,
string $body,
int $parts,
?string $provider = null,
?string $source = null,
bool $isAlert = false
): ?array {
$data = $this->requestJson('POST', '/api/remote/sms/consume', 'consume', [], [
'receiver' => $receiver,
'body' => mb_substr($body, 0, 1000),
'parts' => max(1, $parts),
'provider' => $provider,
'source' => $source,
'isAlert' => $isAlert,
], 6, 'consume');
if ($data === null || !($data['ok'] ?? false)) {
return null;
}
return [
'ok' => true,
'balanceRials' => (int) ($data['balanceRials'] ?? 0),
'costRials' => (int) ($data['costRials'] ?? 0),
'pricePerPartRials' => (int) ($data['pricePerPartRials'] ?? 0),
];
}
public function overview(): ?array
{
return $this->requestJson('GET', '/api/remote/sms/overview', 'overview', [], null, 6, 'overview');
}
public function balance(): ?array
{
return $this->requestJson('GET', '/api/remote/sms/balance', 'balance', [], null, 4, 'balance');
}
public function chargeInfo(): ?array
{
return $this->requestJson('GET', '/api/remote/sms/charge-info', 'charge-info', [], null, 4, 'charge-info');
}
public function updateChargeInfo(array $payload): ?array
{
return $this->requestJson('PUT', '/api/remote/sms/charge-info', 'charge-info', [], $payload, 6, 'charge-info-update');
}
public function charge(int $amount, ?string $note = null): ?array
{
return $this->requestJson('POST', '/api/remote/sms/charge', 'charge', [], [
'amount' => $amount,
'note' => $note,
], 8, 'charge');
}
public function updatePrice(int $pricePerPart): ?array
{
return $this->requestJson('PUT', '/api/remote/sms/price', 'price', [], [
'price_per_part' => max(0, $pricePerPart),
], 6, 'price');
}
public function setBlocked(bool $isBlocked): ?array
{
return $this->requestJson('PUT', '/api/remote/sms/block', 'block', [], [
'is_blocked' => $isBlocked,
], 6, 'block');
}
public function logsTable(array $payload): ?array
{
return $this->requestJson('POST', '/api/remote/sms/logs/table', 'logs/table', [], $payload, 10, 'logs-table');
}
public function ledgerTable(array $payload): ?array
{
return $this->requestJson('POST', '/api/remote/sms/ledger/table', 'ledger/table', [], $payload, 10, 'ledger-table');
}
public function report(?string $fromDate = null, ?string $toDate = null): ?array
{
$query = array_filter([
'from_date' => $fromDate,
'to_date' => $toDate,
], static fn ($v) => $v !== null && $v !== '');
return $this->requestJson('GET', '/api/remote/sms/report', 'report', $query, null, 8, 'report');
}
public function chargeReport(?string $fromDate = null, ?string $toDate = null): ?array
{
$query = array_filter([
'from_date' => $fromDate,
'to_date' => $toDate,
], static fn ($v) => $v !== null && $v !== '');
return $this->requestJson('GET', '/api/remote/sms/report/charges', 'report/charges', $query, null, 8, 'charge-report');
}
/**
* @param array<string, mixed> $query
* @param array<string, mixed>|null $body
* @return array<string, mixed>|null
*/
private function requestJson(
string $method,
string $canonicalPath,
string $relativeUrl,
array $query = [],
?array $body = null,
int $timeout = 6,
string $label = 'request'
): ?array {
if (!$this->enabled()) {
return null;
}
$bases = $this->orderedBaseUrls();
$method = strtoupper($method);
$lastError = null;
if ($method === 'GET' && count($bases) > 1) {
$parallel = $this->requestJsonParallel(
$bases,
$canonicalPath,
$relativeUrl,
$query,
$timeout,
$label
);
if ($parallel !== null) {
return $parallel;
}
$this->forgetSuccessfulBase();
Log::warning("SuperAdmin SMS {$label} unavailable on all bases (parallel GET)", [
'bases' => $bases,
]);
return null;
}
foreach ($bases as $index => $base) {
try {
$pending = Http::withHeaders($this->authHeaders($method, $canonicalPath))
->acceptJson()
->timeout($timeout)
->withOptions(Controller::httpClientOptions([], (float) $timeout));
$url = $this->urlForBase($base, $relativeUrl);
/** @var Response $response */
$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()) {
$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(),
]);
}
}
$this->forgetSuccessfulBase();
Log::warning("SuperAdmin SMS {$label} unavailable on all bases", [
'bases' => $bases,
'last_error' => $lastError,
]);
return null;
}
/**
* Fan-out GET to all SuperAdmin hosts in parallel (max wait = one timeout, not N×timeout).
*
* @param list<string> $bases
* @param array<string, mixed> $query
* @return array<string, mixed>|null
*/
private function requestJsonParallel(
array $bases,
string $canonicalPath,
string $relativeUrl,
array $query,
int $timeout,
string $label
): ?array {
$headers = $this->authHeaders('GET', $canonicalPath);
$options = Controller::httpClientOptions([], (float) $timeout);
try {
/** @var array<string, Response|\Throwable> $responses */
$responses = Http::pool(function (Pool $pool) use ($bases, $headers, $relativeUrl, $query, $timeout, $options) {
foreach ($bases as $index => $base) {
$pool->as((string) $index)
->withHeaders($headers)
->acceptJson()
->connectTimeout(2)
->timeout($timeout)
->withOptions($options)
->get($this->urlForBase($base, $relativeUrl), $query);
}
});
} catch (\Throwable $e) {
Log::warning("SuperAdmin SMS {$label} parallel pool failed", [
'error' => $e->getMessage(),
]);
return null;
}
foreach ($bases as $index => $base) {
$key = (string) $index;
if (! array_key_exists($key, $responses)) {
continue;
}
$response = $responses[$key];
if ($response instanceof \Throwable) {
Log::warning("SuperAdmin SMS {$label} parallel exception", [
'base' => $base,
'error' => $response->getMessage(),
]);
continue;
}
if (! $response->successful()) {
Log::warning("SuperAdmin SMS {$label} parallel failed", [
'base' => $base,
'status' => $response->status(),
'body' => $response->body(),
]);
continue;
}
$data = $response->json();
if (! is_array($data)) {
Log::warning("SuperAdmin SMS {$label} parallel invalid JSON", [
'base' => $base,
]);
continue;
}
$this->rememberSuccessfulBase($base);
return $data;
}
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 forgetSuccessfulBase(): void
{
try {
Cache::forget(self::SUCCESS_BASE_CACHE_KEY);
} catch (\Throwable $e) {
Log::debug('SuperAdmin SMS sticky base cache forget 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 $this->normalizeBaseUrl($cached);
} catch (\Throwable $e) {
return null;
}
}
/**
* @return array<string, string>
*/
private function authHeaders(string $method, string $canonicalPath): array
{
$secret = $this->sharedSecret();
if ($secret !== '') {
$domain = $this->domain();
$timestamp = (string) time();
$payload = $timestamp.'.'.strtoupper($method).'.'.$canonicalPath.'.'.$domain;
$signature = hash_hmac('sha256', $payload, $secret);
return [
'X-Abshode-Domain' => $domain,
'X-Abshode-Timestamp' => $timestamp,
'X-Abshode-Signature' => $signature,
];
}
$token = $this->bearerToken();
if ($token !== '') {
return [
'Authorization' => 'Bearer '.$token,
];
}
return [];
}
private function sharedSecret(): string
{
return trim((string) config('services.superadmin.sms_shared_secret', ''));
}
private function bearerToken(): string
{
return trim((string) config('services.superadmin.remote_token', ''));
}
private function domain(): string
{
$override = trim((string) config('services.superadmin.sms_domain', ''));
if ($override !== '') {
return $this->normalizeHost($override);
}
$fromApp = parse_url((string) config('app.url'), PHP_URL_HOST);
return $this->normalizeHost(is_string($fromApp) ? $fromApp : '');
}
private function normalizeHost(string $value): string
{
$host = strtolower(trim($value));
$host = preg_replace('#^https?://#', '', $host) ?? $host;
$host = explode('/', $host)[0] ?? $host;
$host = explode(':', $host)[0] ?? $host;
return $host;
}
}