add SMSpanel
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user