add SMSpanel

This commit is contained in:
2026-07-04 11:55:10 +03:30
parent 1ce2e73ea9
commit 163097f42a
22 changed files with 6683 additions and 2214 deletions
@@ -0,0 +1,13 @@
<?php
namespace App\Services\SmsBilling\Contracts;
interface SmsProviderInterface
{
public function name(): string;
public function supports(array $config): bool;
/** @return array{success: bool, raw: mixed} */
public function send(string $to, string $message, array $config): array;
}
@@ -0,0 +1,69 @@
<?php
namespace App\Services\SmsBilling\Providers;
use App\Http\Controllers\Controller;
use App\Services\SmsBilling\Contracts\SmsProviderInterface;
use Illuminate\Support\Facades\Http;
class CentralSmsApiProvider implements SmsProviderInterface
{
public function name(): string
{
return 'rayegansms';
}
public function supports(array $config): bool
{
$meliUser = trim((string) ($config['melipayamak_user'] ?? ''));
return strlen($meliUser) <= 1;
}
public function send(string $to, string $message, array $config): array
{
$http = Http::timeout(15)
->withHeaders([
'Content-Type' => 'application/json',
'secret_key' => 'f21debfb-83ac-4624-a2f4-f08375043181',
]);
$curlOptions = Controller::httpClientOptions();
if ($curlOptions !== []) {
$http = $http->withOptions($curlOptions);
}
$host = trim((string) ($config['name_fa'] ?? ''));
if ($host === '') {
$host = trim((string) ($config['name_en'] ?? ''));
}
if ($host === '') {
$host = gethostname();
}
$response = $http->post('http://185.231.112.101/api/createsms', [
'phone' => $to,
'host' => $host,
'text' => trim($message),
'platform' => 'rayegansms',
]);
$body = $response->json() ?? $response->body();
$success = $response->successful();
if (is_array($body) && $success) {
if (array_key_exists('success', $body) && $body['success'] === false) {
$success = false;
} elseif (array_key_exists('ok', $body) && $body['ok'] === false) {
$success = false;
} elseif (isset($body['status']) && in_array(strtolower((string) $body['status']), ['error', 'failed', 'fail'], true)) {
$success = false;
}
}
return [
'success' => $success,
'raw' => $body,
];
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Services\SmsBilling\Providers;
use App\Services\SmsBilling\Contracts\SmsProviderInterface;
class MeliPayamakProvider implements SmsProviderInterface
{
public function name(): string
{
return 'melipayamak';
}
public function supports(array $config): bool
{
$meliUser = trim((string) ($config['melipayamak_user'] ?? ''));
return strlen($meliUser) > 1;
}
public function send(string $to, string $message, array $config): array
{
$data = [
'username' => $config['melipayamak_user'],
'password' => $config['melipayamak_password'] ?? '',
'to' => $to,
'from' => $config['melipayamak_from'] ?? '',
'text' => $message,
];
$handle = curl_init('https://rest.payamak-panel.com/api/SendSMS/SendSMS');
curl_setopt_array($handle, [
CURLOPT_HTTPHEADER => ['content-type: application/x-www-form-urlencoded'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($data),
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($handle);
$error = curl_error($handle);
curl_close($handle);
if ($response === false) {
return ['success' => false, 'raw' => $error];
}
$decoded = json_decode($response, true);
if (is_array($decoded)) {
$status = $decoded['RetStatus'] ?? $decoded['retStatus'] ?? null;
return [
'success' => (int) $status === 1,
'raw' => $decoded,
];
}
$text = trim((string) $response);
return [
'success' => $text !== '' && stripos($text, 'error') === false,
'raw' => $response,
];
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Services\SmsBilling;
use App\Models\Setting;
use App\Models\SmsWallet;
use App\Models\User;
class SmsAlertService
{
public const MAX_ALERT_DAYS = 3;
public function __construct(
private SmsWalletService $walletService,
) {
}
public function maybeSendLowBalanceAlerts(SmsWallet $wallet): void
{
if (! SmsWalletService::isBillingEnabled()) {
return;
}
if ($wallet->balance > 0) {
return;
}
if ((int) $wallet->alert_days_sent >= self::MAX_ALERT_DAYS) {
return;
}
$today = now()->toDateString();
if ($wallet->last_alert_date && $wallet->last_alert_date->toDateString() === $today) {
return;
}
$appName = trim((string) Setting::where('key', 'name_fa')->value('value'));
if ($appName === '') {
$appName = 'این نسخه';
}
$message = "موجودی کیف پول پیامک {$appName} صفر یا منفی شده است. لطفاً کیف پول را شارژ کنید.";
foreach ($this->alertRecipients() as $mobile) {
app(SmsGatewayService::class)->send($mobile, $message, 'wallet_alert', isAlert: true);
}
$wallet->refresh();
$wallet->alert_days_sent = (int) $wallet->alert_days_sent + 1;
$wallet->last_alert_date = $today;
$wallet->save();
}
/** @return list<string> */
private function alertRecipients(): array
{
$numbers = [SmsWalletService::SUPER_ADMIN_MOBILE];
$adminNumbers = User::query()
->where('send_sms', true)
->where('active', true)
->pluck('mobile')
->all();
foreach ($adminNumbers as $number) {
$normalized = trim((string) $number);
if ($normalized !== '') {
$numbers[] = $normalized;
}
}
return array_values(array_unique($numbers));
}
}
@@ -0,0 +1,157 @@
<?php
namespace App\Services\SmsBilling;
use App\Exceptions\SmsBlockedException;
use App\Models\Setting;
use App\Models\User;
use App\Services\SmsBilling\Contracts\SmsProviderInterface;
use App\Services\SmsBilling\Providers\CentralSmsApiProvider;
use App\Services\SmsBilling\Providers\MeliPayamakProvider;
use Illuminate\Support\Facades\Log;
class SmsGatewayService
{
/** @var list<SmsProviderInterface> */
private array $providers;
public function __construct(
private SmsWalletService $walletService,
private SmsSegmentCalculator $segmentCalculator,
private SmsAlertService $alertService,
MeliPayamakProvider $meliPayamakProvider,
CentralSmsApiProvider $centralSmsApiProvider,
) {
$this->providers = [
$meliPayamakProvider,
$centralSmsApiProvider,
];
}
public function send(
string $to,
string $message,
?string $source = null,
bool $isAlert = false
): SmsSendResult {
$config = $this->buildProviderConfig();
$billingEnabled = SmsWalletService::isBillingEnabled();
if ($billingEnabled && $this->walletService->isBlocked()) {
return SmsSendResult::blocked();
}
$provider = $this->resolveProvider($config);
if (! $provider) {
Log::warning('SmsGateway: no SMS provider configured');
return SmsSendResult::failed('سرویس پیامک پیکربندی نشده است.');
}
$text = trim($message);
if ($text === '') {
return SmsSendResult::failed('متن پیامک خالی است.');
}
try {
$result = $provider->send($to, $text, $config);
if (! ($result['success'] ?? false)) {
Log::warning('SmsGateway: provider send failed', [
'provider' => $this->providerLabel($provider),
'to' => $to,
'raw' => $result['raw'] ?? null,
]);
return SmsSendResult::failed('ارسال پیامک ناموفق بود.');
}
$parts = $this->segmentCalculator->countParts($text);
if ($parts <= 0) {
return SmsSendResult::failed('محاسبه پارت پیامک نامعتبر است.');
}
$providerName = $this->providerLabel($provider);
if ($billingEnabled) {
$debit = $this->walletService->debitSuccessfulSms(
$to,
$text,
$parts,
$providerName,
$source,
$isAlert
);
if (! $isAlert) {
$this->alertService->maybeSendLowBalanceAlerts($debit['wallet']);
}
}
return SmsSendResult::success($providerName);
} catch (\Throwable $e) {
Log::error('SmsGateway: exception during send', [
'to' => $to,
'message' => $e->getMessage(),
]);
return SmsSendResult::failed($e->getMessage());
}
}
public function sendOrThrow(
string $to,
string $message,
?string $source = null,
bool $isAlert = false
): SmsSendResult {
$result = $this->send($to, $message, $source, $isAlert);
if ($result->blocked) {
throw new SmsBlockedException();
}
return $result;
}
private function buildProviderConfig(): array
{
return [
'melipayamak_user' => Setting::where('key', 'melipayamak_user')->value('value'),
'melipayamak_password' => Setting::where('key', 'melipayamak_password')->value('value'),
'melipayamak_from' => Setting::where('key', 'melipayamak_from')->value('value'),
'name_fa' => Setting::where('key', 'name_fa')->value('value'),
'name_en' => Setting::where('key', 'name_en')->value('value'),
];
}
private function resolveProvider(array $config): ?SmsProviderInterface
{
$meliUser = trim((string) ($config['melipayamak_user'] ?? ''));
if (strlen($meliUser) > 1) {
return $this->providerByClass(MeliPayamakProvider::class);
}
return $this->providerByClass(CentralSmsApiProvider::class);
}
private function providerByClass(string $class): ?SmsProviderInterface
{
foreach ($this->providers as $provider) {
if ($provider instanceof $class) {
return $provider;
}
}
return null;
}
private function providerLabel(SmsProviderInterface $provider): string
{
return $provider->name();
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Services\SmsBilling;
class SmsSegmentCalculator
{
private const PERSIAN_LIMITS = [70, 64, 67];
public function countParts(string $text): int
{
$length = mb_strlen(trim($text), 'UTF-8');
if ($length <= 0) {
return 0;
}
$first = self::PERSIAN_LIMITS[0];
$second = self::PERSIAN_LIMITS[1];
$rest = self::PERSIAN_LIMITS[2];
if ($length <= $first) {
return 1;
}
if ($length <= $first + $second) {
return 2;
}
$thirdThreshold = $first + $second + $rest;
if ($length <= $thirdThreshold) {
return 3;
}
return 3 + (int) ceil(($length - $thirdThreshold) / $rest);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Services\SmsBilling;
class SmsSendResult
{
public function __construct(
public readonly bool $sent,
public readonly bool $blocked = false,
public readonly ?string $provider = null,
public readonly ?string $errorMessage = null,
) {
}
public static function blocked(): self
{
return new self(sent: false, blocked: true, errorMessage: 'ارسال پیامک موقتاً غیرفعال است');
}
public static function failed(?string $message = null): self
{
return new self(sent: false, errorMessage: $message);
}
public static function success(string $provider): self
{
return new self(sent: true, provider: $provider);
}
}
@@ -0,0 +1,179 @@
<?php
namespace App\Services\SmsBilling;
use App\Models\Setting;
use App\Models\SmsLedger;
use App\Models\SmsSendLog;
use App\Models\SmsWallet;
use Illuminate\Support\Facades\DB;
class SmsWalletService
{
public const SUPER_ADMIN_MOBILE = '09128081392';
public const TYPE_CHARGE = 'charge';
public const TYPE_DEBIT = 'debit';
public const TYPE_ALERT_DEBIT = 'alert_debit';
/** Billing/panel فعال است مگر sms_panel صریحاً false باشد. */
public static function isBillingEnabled(): bool
{
$value = Setting::where('key', 'sms_panel')->value('value');
if ($value === null || trim((string) $value) === '') {
return true;
}
$normalized = strtolower(trim((string) $value));
return ! in_array($normalized, ['false', '0', 'no', 'off'], true);
}
public function getWallet(): SmsWallet
{
return SmsWallet::query()->firstOrCreate([], [
'balance' => 0,
'price_per_part' => 0,
'is_blocked' => false,
'alert_days_sent' => 0,
]);
}
public function isBlocked(): bool
{
if (! self::isBillingEnabled()) {
return false;
}
return (bool) $this->getWallet()->is_blocked;
}
public function setBlocked(bool $blocked): SmsWallet
{
$wallet = $this->getWallet();
$wallet->is_blocked = $blocked;
$wallet->save();
return $wallet->fresh();
}
public function updatePricePerPart(int $pricePerPart): SmsWallet
{
$wallet = $this->getWallet();
$wallet->price_per_part = max(0, $pricePerPart);
$wallet->save();
return $wallet;
}
public function charge(int $amount, ?int $adminUserId = null, ?string $note = null): SmsWallet
{
if ($amount <= 0) {
throw new \InvalidArgumentException('مبلغ شارژ باید بزرگتر از صفر باشد.');
}
return DB::transaction(function () use ($amount, $adminUserId, $note) {
$wallet = SmsWallet::query()->lockForUpdate()->firstOrCreate([], [
'balance' => 0,
'price_per_part' => 0,
'is_blocked' => false,
'alert_days_sent' => 0,
]);
$wallet->balance += $amount;
if ($wallet->balance > 0) {
$wallet->alert_days_sent = 0;
$wallet->last_alert_date = null;
}
$wallet->save();
SmsLedger::create([
'type' => self::TYPE_CHARGE,
'amount' => $amount,
'balance_after' => $wallet->balance,
'note' => $note,
'created_by' => $adminUserId,
'created_at' => now(),
]);
return $wallet;
});
}
/**
* @return array{log: SmsSendLog, wallet: SmsWallet}
*/
public function debitSuccessfulSms(
string $receiver,
string $body,
int $parts,
string $provider,
?string $source = null,
bool $isAlert = false
): array {
return DB::transaction(function () use ($receiver, $body, $parts, $provider, $source, $isAlert) {
$wallet = SmsWallet::query()->lockForUpdate()->firstOrCreate([], [
'balance' => 0,
'price_per_part' => 0,
'is_blocked' => false,
'alert_days_sent' => 0,
]);
$pricePerPart = (int) $wallet->price_per_part;
$totalAmount = $parts * $pricePerPart;
$wallet->balance -= $totalAmount;
$wallet->save();
$log = SmsSendLog::create([
'receiver' => $receiver,
'body' => $body,
'parts' => $parts,
'price_per_part' => $pricePerPart,
'total_amount' => $totalAmount,
'provider' => $provider,
'source' => $source,
'is_alert' => $isAlert,
'created_at' => now(),
]);
SmsLedger::create([
'type' => $isAlert ? self::TYPE_ALERT_DEBIT : self::TYPE_DEBIT,
'amount' => -$totalAmount,
'balance_after' => $wallet->balance,
'price_per_part' => $pricePerPart,
'parts' => $parts,
'sms_send_log_id' => $log->id,
'note' => $isAlert ? 'هشدار موجودی پیامک' : null,
'created_at' => now(),
]);
return ['log' => $log, 'wallet' => $wallet->fresh()];
});
}
public static function isSmsPanelSuperAdmin(?object $user): bool
{
if (! $user) {
return false;
}
return self::normalizeMobile((string) ($user->mobile ?? '')) === self::SUPER_ADMIN_MOBILE;
}
public static function normalizeMobile(string $mobile): string
{
$digits = preg_replace('/\D/', '', $mobile) ?? '';
if (str_starts_with($digits, '98') && strlen($digits) === 12) {
$digits = '0'.substr($digits, 2);
} elseif (strlen($digits) === 10 && str_starts_with($digits, '9')) {
$digits = '0'.$digits;
}
return $digits;
}
}