535 lines
16 KiB
PHP
535 lines
16 KiB
PHP
<?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;
|
||
}
|
||
}
|