add SMSpanel
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class SmsBlockedException extends Exception
|
||||
{
|
||||
public function __construct(string $message = 'ارسال پیامک موقتاً غیرفعال است')
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\DB\Te\TableEngine;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SmsLedger;
|
||||
use App\Models\SmsSendLog;
|
||||
use App\Services\SmsBilling\SmsWalletService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SmsPanelController extends Controller
|
||||
{
|
||||
public function overview()
|
||||
{
|
||||
$wallet = app(SmsWalletService::class)->getWallet();
|
||||
|
||||
$monthStart = Carbon::now()->startOfMonth();
|
||||
|
||||
$monthStats = SmsSendLog::query()
|
||||
->where('created_at', '>=', $monthStart)
|
||||
->selectRaw('COALESCE(SUM(parts), 0) as total_parts, COALESCE(SUM(total_amount), 0) as total_amount, COUNT(*) as total_messages')
|
||||
->first();
|
||||
|
||||
$todayStats = SmsSendLog::query()
|
||||
->whereDate('created_at', Carbon::today())
|
||||
->selectRaw('COALESCE(SUM(parts), 0) as total_parts, COALESCE(SUM(total_amount), 0) as total_amount, COUNT(*) as total_messages')
|
||||
->first();
|
||||
|
||||
return response()->json([
|
||||
'wallet' => [
|
||||
'balance' => (int) $wallet->balance,
|
||||
'price_per_part' => (int) $wallet->price_per_part,
|
||||
'is_blocked' => (bool) $wallet->is_blocked,
|
||||
'alert_days_sent' => (int) $wallet->alert_days_sent,
|
||||
'last_alert_date' => $wallet->last_alert_date?->toDateString(),
|
||||
],
|
||||
'stats' => [
|
||||
'today' => [
|
||||
'parts' => (int) ($todayStats->total_parts ?? 0),
|
||||
'amount' => (int) ($todayStats->total_amount ?? 0),
|
||||
'messages' => (int) ($todayStats->total_messages ?? 0),
|
||||
],
|
||||
'month' => [
|
||||
'parts' => (int) ($monthStats->total_parts ?? 0),
|
||||
'amount' => (int) ($monthStats->total_amount ?? 0),
|
||||
'messages' => (int) ($monthStats->total_messages ?? 0),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function charge(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'amount' => 'required|integer|min:1',
|
||||
'note' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$wallet = app(SmsWalletService::class)->charge(
|
||||
(int) $data['amount'],
|
||||
auth()->id(),
|
||||
$data['note'] ?? null
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'کیف پول با موفقیت شارژ شد.',
|
||||
'balance' => (int) $wallet->balance,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatePrice(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'price_per_part' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
$wallet = app(SmsWalletService::class)->updatePricePerPart((int) $data['price_per_part']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'قیمت هر پارت بهروزرسانی شد.',
|
||||
'price_per_part' => (int) $wallet->price_per_part,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateBlock(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'is_blocked' => 'required|boolean',
|
||||
]);
|
||||
|
||||
$wallet = app(SmsWalletService::class)->setBlocked((bool) $data['is_blocked']);
|
||||
|
||||
return response()->json([
|
||||
'message' => $wallet->is_blocked ? 'ارسال پیامک مسدود شد.' : 'ارسال پیامک فعال شد.',
|
||||
'is_blocked' => (bool) $wallet->is_blocked,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logsTable(Request $request)
|
||||
{
|
||||
$records = SmsSendLog::query()->orderByDesc('id');
|
||||
|
||||
if ($request->filled('from_date')) {
|
||||
$records->whereDate('created_at', '>=', $request->input('from_date'));
|
||||
}
|
||||
|
||||
if ($request->filled('to_date')) {
|
||||
$records->whereDate('created_at', '<=', $request->input('to_date'));
|
||||
}
|
||||
|
||||
$table = TableEngine::create($records, $request->all())
|
||||
->initSearch(['receiver', 'body', 'provider', 'source'])
|
||||
->select([
|
||||
'id',
|
||||
'receiver',
|
||||
'body',
|
||||
'parts',
|
||||
'price_per_part',
|
||||
'total_amount',
|
||||
'provider',
|
||||
'source',
|
||||
'is_alert',
|
||||
'created_at',
|
||||
]);
|
||||
|
||||
return response()->json($table->export());
|
||||
}
|
||||
|
||||
public function ledgerTable(Request $request)
|
||||
{
|
||||
$records = SmsLedger::query()->orderByDesc('id');
|
||||
|
||||
if ($request->filled('from_date')) {
|
||||
$records->whereDate('created_at', '>=', $request->input('from_date'));
|
||||
}
|
||||
|
||||
if ($request->filled('to_date')) {
|
||||
$records->whereDate('created_at', '<=', $request->input('to_date'));
|
||||
}
|
||||
|
||||
$table = TableEngine::create($records, $request->all())
|
||||
->initSearch(['type', 'note'])
|
||||
->select([
|
||||
'id',
|
||||
'type',
|
||||
'amount',
|
||||
'balance_after',
|
||||
'price_per_part',
|
||||
'parts',
|
||||
'note',
|
||||
'created_by',
|
||||
'created_at',
|
||||
]);
|
||||
|
||||
return response()->json($table->export());
|
||||
}
|
||||
|
||||
public function report(Request $request)
|
||||
{
|
||||
$from = $request->input('from_date', Carbon::now()->startOfMonth()->toDateString());
|
||||
$to = $request->input('to_date', Carbon::now()->toDateString());
|
||||
|
||||
$summary = SmsSendLog::query()
|
||||
->whereDate('created_at', '>=', $from)
|
||||
->whereDate('created_at', '<=', $to)
|
||||
->selectRaw('COALESCE(SUM(parts), 0) as total_parts, COALESCE(SUM(total_amount), 0) as total_amount, COUNT(*) as total_messages')
|
||||
->first();
|
||||
|
||||
$byProvider = SmsSendLog::query()
|
||||
->whereDate('created_at', '>=', $from)
|
||||
->whereDate('created_at', '<=', $to)
|
||||
->select('provider', DB::raw('COUNT(*) as messages'), DB::raw('SUM(parts) as parts'), DB::raw('SUM(total_amount) as amount'))
|
||||
->groupBy('provider')
|
||||
->get();
|
||||
|
||||
$wallet = app(SmsWalletService::class)->getWallet();
|
||||
|
||||
return response()->json([
|
||||
'from_date' => $from,
|
||||
'to_date' => $to,
|
||||
'balance' => (int) $wallet->balance,
|
||||
'summary' => [
|
||||
'parts' => (int) ($summary->total_parts ?? 0),
|
||||
'amount' => (int) ($summary->total_amount ?? 0),
|
||||
'messages' => (int) ($summary->total_messages ?? 0),
|
||||
],
|
||||
'by_provider' => $byProvider,
|
||||
]);
|
||||
}
|
||||
|
||||
public function chargeReport(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'from_date' => 'nullable|date',
|
||||
'to_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$baseQuery = SmsLedger::query()->where('type', SmsWalletService::TYPE_CHARGE);
|
||||
|
||||
if ($request->filled('from_date')) {
|
||||
$baseQuery->whereDate('created_at', '>=', $request->input('from_date'));
|
||||
}
|
||||
|
||||
if ($request->filled('to_date')) {
|
||||
$baseQuery->whereDate('created_at', '<=', $request->input('to_date'));
|
||||
}
|
||||
|
||||
$summary = (clone $baseQuery)
|
||||
->selectRaw('COUNT(*) as total_charges, COALESCE(SUM(amount), 0) as total_amount')
|
||||
->first();
|
||||
|
||||
$items = (clone $baseQuery)
|
||||
->orderByDesc('created_at')
|
||||
->select([
|
||||
'id',
|
||||
'amount',
|
||||
'balance_after',
|
||||
'note',
|
||||
'created_by',
|
||||
'created_at',
|
||||
])
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'from_date' => $request->input('from_date'),
|
||||
'to_date' => $request->input('to_date'),
|
||||
'summary' => [
|
||||
'charges' => (int) ($summary->total_charges ?? 0),
|
||||
'amount' => (int) ($summary->total_amount ?? 0),
|
||||
],
|
||||
'items' => $items,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\Permission;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Setting;
|
||||
use App\Services\SmsBilling\SmsWalletService;
|
||||
|
||||
class UserController extends SuperController
|
||||
{
|
||||
@@ -33,6 +34,11 @@ class UserController extends SuperController
|
||||
$query = Setting::query();
|
||||
$settings = $query->get();
|
||||
$user->settings= $settings;
|
||||
$user->setAttribute('sms_billing_enabled', SmsWalletService::isBillingEnabled());
|
||||
$user->setAttribute(
|
||||
'sms_panel_access',
|
||||
SmsWalletService::isBillingEnabled() && SmsWalletService::isSmsPanelSuperAdmin($user)
|
||||
);
|
||||
return response()->json($user);
|
||||
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ use App\Models\User;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Message;
|
||||
use App\Exceptions\SmsBlockedException;
|
||||
use App\Services\ConfigService;
|
||||
use App\Services\SmsBilling\SmsGatewayService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
@@ -103,9 +105,6 @@ class Controller extends BaseController
|
||||
|
||||
public static function SendMessage($to, $msg ,$is_send = false,$force_method ='null')
|
||||
{
|
||||
|
||||
$config = app(ConfigService::class);
|
||||
// $config = config('message', false);
|
||||
$credentials = [
|
||||
"telegram" => [
|
||||
"token" => self::Config('telegram_bot_token')
|
||||
@@ -148,44 +147,28 @@ class Controller extends BaseController
|
||||
|
||||
//SMS
|
||||
if ($send_sms or $force_method=="sms") {
|
||||
/* $endpoint = "https://www.0098sms.com/sendsmslink.aspx";
|
||||
$response = Http::withoutVerifying()->get($endpoint, [
|
||||
'FROM' => $credentials['sms']['sender'],
|
||||
'TO' => $to,
|
||||
'TEXT' => trim($msg),
|
||||
'USERNAME' => $credentials['sms']['user_name'],
|
||||
'PASSWORD' => $credentials['sms']['password'],
|
||||
'DOMAIN' => '0098',
|
||||
]);
|
||||
$platforms[] = "0098:{$to}";*/
|
||||
|
||||
try {
|
||||
$http = Http::timeout(15)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/json',
|
||||
'secret_key' => 'f21debfb-83ac-4624-a2f4-f08375043181',
|
||||
]);
|
||||
$gateway = app(SmsGatewayService::class);
|
||||
|
||||
$curlOptions = self::httpClientOptions();
|
||||
if ($curlOptions !== []) {
|
||||
$http = $http->withOptions($curlOptions);
|
||||
if ($force_method == "sms") {
|
||||
$smsResult = $gateway->sendOrThrow($to, trim($msg), 'notification');
|
||||
} else {
|
||||
$smsResult = $gateway->send($to, trim($msg), 'notification');
|
||||
}
|
||||
|
||||
$response = $http->post('http://185.231.112.101/api/createsms', [
|
||||
'phone' => $to,
|
||||
'host' => $config->Config('name_fa') ?? gethostname(),
|
||||
'text' => trim($msg),
|
||||
'platform' => 'rayegansms',
|
||||
]);
|
||||
|
||||
$platforms[] = $response->successful()
|
||||
? "rayegansms:{$to}"
|
||||
: "rayegansms_failed:{$to}:{$response->status()}";
|
||||
if ($smsResult->blocked) {
|
||||
$platforms[] = "blocked:{$to}";
|
||||
} elseif ($smsResult->sent && $smsResult->provider) {
|
||||
$platforms[] = "{$smsResult->provider}:{$to}";
|
||||
} else {
|
||||
$platforms[] = "sms_failed:{$to}";
|
||||
}
|
||||
} catch (SmsBlockedException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$platforms[] = "rayegansms_error:{$to}";
|
||||
$platforms[] = "sms_error:{$to}";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Message::create([
|
||||
|
||||
@@ -67,5 +67,6 @@ class Kernel extends HttpKernel
|
||||
'validation'=>ValidationMiddleware::class,
|
||||
'request.validation' => \App\Http\Middleware\ValidateRequests::class,
|
||||
'LogRequests' => \App\Http\Middleware\LogRequests::class,
|
||||
'sms.panel.super' => \App\Http\Middleware\EnsureSmsPanelSuperAdmin::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\SmsBilling\SmsWalletService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EnsureSmsPanelSuperAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$user = auth('user')->user();
|
||||
|
||||
if (! SmsWalletService::isBillingEnabled()) {
|
||||
return response()->json(['message' => 'پنل پیامک برای این نسخه فعال نیست.'], 403);
|
||||
}
|
||||
|
||||
if (! SmsWalletService::isSmsPanelSuperAdmin($user)) {
|
||||
return response()->json(['message' => 'دسترسی به پنل پیامک مجاز نیست.'], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SmsLedger extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'sms_ledger';
|
||||
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'amount',
|
||||
'balance_after',
|
||||
'price_per_part',
|
||||
'parts',
|
||||
'sms_send_log_id',
|
||||
'note',
|
||||
'created_by',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'integer',
|
||||
'balance_after' => 'integer',
|
||||
'price_per_part' => 'integer',
|
||||
'parts' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SmsSendLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'receiver',
|
||||
'body',
|
||||
'parts',
|
||||
'price_per_part',
|
||||
'total_amount',
|
||||
'provider',
|
||||
'source',
|
||||
'is_alert',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'parts' => 'integer',
|
||||
'price_per_part' => 'integer',
|
||||
'total_amount' => 'integer',
|
||||
'is_alert' => 'boolean',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SmsWallet extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'balance',
|
||||
'price_per_part',
|
||||
'is_blocked',
|
||||
'alert_days_sent',
|
||||
'last_alert_date',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'balance' => 'integer',
|
||||
'price_per_part' => 'integer',
|
||||
'is_blocked' => 'boolean',
|
||||
'alert_days_sent' => 'integer',
|
||||
'last_alert_date' => 'date',
|
||||
];
|
||||
}
|
||||
@@ -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