add SuperAdmin SMS client routing
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,7 +4,6 @@ namespace App\Services\SmsBilling;
|
|||||||
|
|
||||||
use App\Exceptions\SmsBlockedException;
|
use App\Exceptions\SmsBlockedException;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\SmsBilling\Contracts\SmsProviderInterface;
|
use App\Services\SmsBilling\Contracts\SmsProviderInterface;
|
||||||
use App\Services\SmsBilling\Providers\CentralSmsApiProvider;
|
use App\Services\SmsBilling\Providers\CentralSmsApiProvider;
|
||||||
use App\Services\SmsBilling\Providers\MeliPayamakProvider;
|
use App\Services\SmsBilling\Providers\MeliPayamakProvider;
|
||||||
@@ -19,6 +18,7 @@ class SmsGatewayService
|
|||||||
private SmsWalletService $walletService,
|
private SmsWalletService $walletService,
|
||||||
private SmsSegmentCalculator $segmentCalculator,
|
private SmsSegmentCalculator $segmentCalculator,
|
||||||
private SmsAlertService $alertService,
|
private SmsAlertService $alertService,
|
||||||
|
private SuperAdminSmsClient $superAdminSms,
|
||||||
MeliPayamakProvider $meliPayamakProvider,
|
MeliPayamakProvider $meliPayamakProvider,
|
||||||
CentralSmsApiProvider $centralSmsApiProvider,
|
CentralSmsApiProvider $centralSmsApiProvider,
|
||||||
) {
|
) {
|
||||||
@@ -35,9 +35,17 @@ class SmsGatewayService
|
|||||||
bool $isAlert = false
|
bool $isAlert = false
|
||||||
): SmsSendResult {
|
): SmsSendResult {
|
||||||
$config = $this->buildProviderConfig();
|
$config = $this->buildProviderConfig();
|
||||||
$billingOperational = SmsWalletService::isBillingOperational();
|
$useSuperAdmin = $this->superAdminSms->enabled();
|
||||||
|
$billingOperational = SmsWalletService::isBillingOperational() || $useSuperAdmin;
|
||||||
|
$text = trim($message);
|
||||||
|
|
||||||
if ($billingOperational && $this->walletService->isBlocked()) {
|
if ($text === '') {
|
||||||
|
return SmsSendResult::failed('متن پیامک خالی است.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$partsEstimate = max(1, $this->segmentCalculator->countParts($text));
|
||||||
|
|
||||||
|
if ($billingOperational && ! $this->maySend($partsEstimate, $isAlert)) {
|
||||||
return SmsSendResult::blocked();
|
return SmsSendResult::blocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,12 +57,6 @@ class SmsGatewayService
|
|||||||
return SmsSendResult::failed('سرویس پیامک پیکربندی نشده است.');
|
return SmsSendResult::failed('سرویس پیامک پیکربندی نشده است.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$text = trim($message);
|
|
||||||
|
|
||||||
if ($text === '') {
|
|
||||||
return SmsSendResult::failed('متن پیامک خالی است.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $provider->send($to, $text, $config);
|
$result = $provider->send($to, $text, $config);
|
||||||
|
|
||||||
@@ -77,18 +79,7 @@ class SmsGatewayService
|
|||||||
$providerName = $this->providerLabel($provider);
|
$providerName = $this->providerLabel($provider);
|
||||||
|
|
||||||
if ($billingOperational) {
|
if ($billingOperational) {
|
||||||
$debit = $this->walletService->debitSuccessfulSms(
|
$this->billAfterSuccess($to, $text, $parts, $providerName, $source, $isAlert);
|
||||||
$to,
|
|
||||||
$text,
|
|
||||||
$parts,
|
|
||||||
$providerName,
|
|
||||||
$source,
|
|
||||||
$isAlert
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $isAlert) {
|
|
||||||
$this->alertService->maybeSendLowBalanceAlerts($debit['wallet']);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return SmsSendResult::success($providerName);
|
return SmsSendResult::success($providerName);
|
||||||
@@ -117,6 +108,63 @@ class SmsGatewayService
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function maySend(int $parts, bool $isAlert): bool
|
||||||
|
{
|
||||||
|
if ($this->superAdminSms->enabled()) {
|
||||||
|
$status = $this->superAdminSms->status($parts, $isAlert);
|
||||||
|
if ($status === null) {
|
||||||
|
return ! $this->walletService->isBlocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $status['allowed'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $this->walletService->isBlocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function billAfterSuccess(
|
||||||
|
string $to,
|
||||||
|
string $text,
|
||||||
|
int $parts,
|
||||||
|
string $providerName,
|
||||||
|
?string $source,
|
||||||
|
bool $isAlert
|
||||||
|
): void {
|
||||||
|
if ($this->superAdminSms->enabled()) {
|
||||||
|
$consumed = $this->superAdminSms->consume(
|
||||||
|
$to,
|
||||||
|
$text,
|
||||||
|
$parts,
|
||||||
|
$providerName,
|
||||||
|
$source,
|
||||||
|
$isAlert
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($consumed === null) {
|
||||||
|
Log::error('SmsGateway: SuperAdmin consume failed after successful send', [
|
||||||
|
'to' => $to,
|
||||||
|
'parts' => $parts,
|
||||||
|
'source' => $source,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$debit = $this->walletService->debitSuccessfulSms(
|
||||||
|
$to,
|
||||||
|
$text,
|
||||||
|
$parts,
|
||||||
|
$providerName,
|
||||||
|
$source,
|
||||||
|
$isAlert
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $isAlert) {
|
||||||
|
$this->alertService->maybeSendLowBalanceAlerts($debit['wallet']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function buildProviderConfig(): array
|
private function buildProviderConfig(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\SmsBilling;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SuperAdmin SMS wallet client (status / consume).
|
||||||
|
*
|
||||||
|
* Preferred: SUPERADMIN_SMS_BASE_URL + SUPERADMIN_SMS_SHARED_SECRET (+ optional DOMAIN)
|
||||||
|
* Fallback: SUPERADMIN_REMOTE_TOKEN
|
||||||
|
*/
|
||||||
|
class SuperAdminSmsClient
|
||||||
|
{
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
$base = trim((string) config('services.superadmin.sms_base_url', ''));
|
||||||
|
if ($base === '') {
|
||||||
|
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
|
||||||
|
{
|
||||||
|
if (! $this->enabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$path = '/api/remote/sms/status';
|
||||||
|
$response = Http::withHeaders($this->authHeaders('GET', $path))
|
||||||
|
->acceptJson()
|
||||||
|
->timeout(4)
|
||||||
|
->get($this->url('status'), [
|
||||||
|
'parts' => max(1, $parts),
|
||||||
|
'isAlert' => $isAlert ? '1' : '0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::warning('SuperAdmin SMS status failed', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
if (! is_array($data)) {
|
||||||
|
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'),
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('SuperAdmin SMS status exception', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 {
|
||||||
|
if (! $this->enabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$path = '/api/remote/sms/consume';
|
||||||
|
$payload = [
|
||||||
|
'receiver' => $receiver,
|
||||||
|
'body' => mb_substr($body, 0, 1000),
|
||||||
|
'parts' => max(1, $parts),
|
||||||
|
'provider' => $provider,
|
||||||
|
'source' => $source,
|
||||||
|
'isAlert' => $isAlert,
|
||||||
|
];
|
||||||
|
$response = Http::withHeaders($this->authHeaders('POST', $path))
|
||||||
|
->acceptJson()
|
||||||
|
->timeout(6)
|
||||||
|
->post($this->url('consume'), $payload);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::error('SuperAdmin SMS consume failed', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
if (! is_array($data) || ! ($data['ok'] ?? false)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'balanceRials' => (int) ($data['balanceRials'] ?? 0),
|
||||||
|
'costRials' => (int) ($data['costRials'] ?? 0),
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('SuperAdmin SMS consume exception', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function url(string $path): string
|
||||||
|
{
|
||||||
|
$base = rtrim((string) config('services.superadmin.sms_base_url', ''), '/');
|
||||||
|
|
||||||
|
return $base.'/'.ltrim($path, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,4 +41,15 @@ return [
|
|||||||
'secret' => env('NODE_SHARED_SECRET'),
|
'secret' => env('NODE_SHARED_SECRET'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
| SuperAdmin SMS wallet (source of truth when both env vars are set).
|
||||||
|
| Example: SUPERADMIN_SMS_BASE_URL=https://adm.montzar.ir/api/remote/sms
|
||||||
|
*/
|
||||||
|
'superadmin' => [
|
||||||
|
'sms_base_url' => env('SUPERADMIN_SMS_BASE_URL'),
|
||||||
|
'sms_shared_secret' => env('SUPERADMIN_SMS_SHARED_SECRET'),
|
||||||
|
'sms_domain' => env('SUPERADMIN_SMS_DOMAIN'),
|
||||||
|
'remote_token' => env('SUPERADMIN_REMOTE_TOKEN'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user