From 64a4a4ca9524145b8e9d0de63162c2655f85af19 Mon Sep 17 00:00:00 2001 From: adonis Date: Tue, 21 Jul 2026 14:23:26 +0330 Subject: [PATCH] fix pargar bugs 6 --- .../Commands/PargarWalletFreezeCommand.php | 123 +++++++ .../Pargar/PargarInboundController.php | 24 +- app/Services/PargarInboundWalletService.php | 316 +++--------------- 3 files changed, 185 insertions(+), 278 deletions(-) create mode 100644 app/Console/Commands/PargarWalletFreezeCommand.php diff --git a/app/Console/Commands/PargarWalletFreezeCommand.php b/app/Console/Commands/PargarWalletFreezeCommand.php new file mode 100644 index 0000000..176cc2c --- /dev/null +++ b/app/Console/Commands/PargarWalletFreezeCommand.php @@ -0,0 +1,123 @@ +argument('action')); + + if ($action === 'on') { + PargarInboundWalletService::freezeApplies(true); + $this->warn('اعمال کیف از پرگار یخ‌زده شد (KILL SWITCH ON)'); + } elseif ($action === 'off') { + PargarInboundWalletService::freezeApplies(false); + $this->info('اعمال کیف از پرگار آزاد شد (KILL SWITCH OFF)'); + } elseif ($action === 'status') { + $frozen = PargarInboundWalletService::isApplyFrozen(); + $this->line($frozen ? 'وضعیت: یخ‌زده (ON)' : 'وضعیت: آزاد (OFF)'); + } else { + $this->error('action باید on|off|status باشد'); + + return self::FAILURE; + } + + $customerId = (int) ($this->option('customer') ?: 0); + if ($customerId > 0) { + $this->reportCustomer($customerId); + $this->maybeSetBalances($customerId); + } + + $this->newLine(); + $this->warn('اگر هنوز موجودی بالا می‌رود: php-fpm / octane / queue را حتماً ری‌استارت کنید.'); + $this->line('درخواست‌های قدیمیِ قبل از دیپلوی با کد قبلی هنوز ممکن است در حال اجرا باشند.'); + + return self::SUCCESS; + } + + private function reportCustomer(int $customerId): void + { + $customer = Customer::find($customerId); + if (! $customer) { + $this->error("مشتری {$customerId} یافت نشد"); + + return; + } + + $wallet = (new WalletInterface($customerId, (int) ($customer->type ?? 2)))->wallet; + $this->info("مشتری {$customerId} — {$customer->name}"); + $this->line(' cash='.((float) ($wallet->cash ?? 0))); + $this->line(' gold='.((float) ($wallet->gold ?? 0))); + + $recent = Transaction::query() + ->where('customer_id', $customerId) + ->where('type', PargarInboundWalletService::TX_TYPE_PARGAR) + ->orderByDesc('id') + ->limit(15) + ->get(['id', 'cash', 'gold', 'title', 'note', 'created_at']); + + $this->line('آخرین تراکنش‌های type=22 (پرگار):'); + foreach ($recent as $row) { + $this->line(sprintf( + ' #%d cash=%s gold=%s at=%s title=%s', + $row->id, + $row->cash, + $row->gold, + $row->created_at, + $row->title + )); + } + + $countLastMinute = Transaction::query() + ->where('customer_id', $customerId) + ->where('type', PargarInboundWalletService::TX_TYPE_PARGAR) + ->where('created_at', '>=', now()->subMinute()) + ->count(); + $this->line("تراکنش type=22 در ۱ دقیقهٔ اخیر: {$countLastMinute}"); + } + + private function maybeSetBalances(int $customerId): void + { + $setCash = $this->option('set-cash'); + $setGold = $this->option('set-gold'); + if ($setCash === null && $setGold === null) { + return; + } + + if (! $this->confirm("موجودی مشتری {$customerId} مستقیم در DB عوض شود؟", false)) { + return; + } + + $customer = Customer::findOrFail($customerId); + $wallet = (new WalletInterface($customerId, (int) ($customer->type ?? 2)))->wallet; + if (! $wallet) { + $this->error('کیف یافت نشد'); + + return; + } + + DB::table($wallet->getTable())->where('id', $wallet->id)->update(array_filter([ + 'cash' => $setCash !== null ? (float) $setCash : null, + 'gold' => $setGold !== null ? (float) $setGold : null, + 'updated_at' => now(), + ], fn ($v) => $v !== null)); + + $this->info('موجودی به‌روز شد. صفحه را رفرش کنید.'); + } +} diff --git a/app/Http/Controllers/Pargar/PargarInboundController.php b/app/Http/Controllers/Pargar/PargarInboundController.php index e00b370..63689eb 100644 --- a/app/Http/Controllers/Pargar/PargarInboundController.php +++ b/app/Http/Controllers/Pargar/PargarInboundController.php @@ -240,13 +240,14 @@ class PargarInboundController extends Controller ? Customer::where('pargar_account_id', $accountId)->first() : null; - // فقط لاگ — اعمال خودکار روی کیف خاموش است (جلوگیری از شارژ چندباره وقتی پرگار مکرر می‌فرستد) + // فقط لاگ — اعمال خودکار روی کیف کاملاً خاموش است + // اگر هنوز موجودی بالا می‌رود = worker/php قدیمی هنوز زنده است → ری‌استارت الزامی PargarSyncLog::create([ 'customer_id' => $customer?->id, 'amount' => $summary['amount'], 'desc' => $customer - ? ($summary['desc'] !== '' ? $summary['desc'] : 'دریافت اسناد از پرگار') - : 'دریافت اسناد از پرگار — مشتری یافت نشد', + ? ('[no-apply] '.($summary['desc'] !== '' ? $summary['desc'] : 'دریافت اسناد از پرگار')) + : '[no-apply] دریافت اسناد از پرگار — مشتری یافت نشد', 'type' => PargarSyncService::TYPE_DOCUMENT, 'sync_status' => PargarSyncService::STATUS_SUCCESS, 'pargar_ref' => $accountId !== '' ? $accountId : null, @@ -254,15 +255,23 @@ class PargarInboundController extends Controller '_summary' => $summary, 'wallet_applied' => false, 'auto_apply_disabled' => true, + 'code_build' => 'no-apply-v2', ]), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), ]); + \Illuminate\Support\Facades\Log::info('Pargar documents stored without wallet apply', [ + 'account_id' => $accountId, + 'customer_id' => $customer?->id, + 'tx_count' => is_array($data['transactions'] ?? null) ? count($data['transactions']) : 0, + ]); + return response()->json([ 'success' => true, - 'message' => $customer - ? 'اسناد مشتری دریافت و ثبت شد (بدون تغییر کیف)' - : 'اسناد دریافت شد ولی مشتری با این کد حساب یافت نشد', - 'data' => null, + 'message' => 'اسناد ثبت شد بدون تغییر کیف (no-apply-v2)', + 'data' => [ + 'wallet_applied' => false, + 'build' => 'no-apply-v2', + ], ]); } @@ -284,6 +293,7 @@ class PargarInboundController extends Controller $rialInv = isset($data['RialInv']) ? (float) str_replace('/', '.', (string) $data['RialInv']) : 0; // فقط مقایسه برای لاگ — اپ source of truth است؛ کیف را از مانده پرگار عوض نکن + // اضطراری: هیچ‌وقت از mande کیف را تغییر نده (حتی اگر کد قدیمی جایی مانده باشد) $ownerType = (int) ($customer->type ?? 2); $walletInterface = new WalletInterface($customer->id, $ownerType); $wallet = $walletInterface->wallet; diff --git a/app/Services/PargarInboundWalletService.php b/app/Services/PargarInboundWalletService.php index 5426f2b..cefa5ea 100644 --- a/app/Services/PargarInboundWalletService.php +++ b/app/Services/PargarInboundWalletService.php @@ -12,15 +12,15 @@ use Rooyesh\Wallet\interfaces\WalletInterface; /** * اعمال اسناد/ماندهٔ ورودی پرگار روی کیف اپ. * - * دکمهٔ ادمین فقط مانده (mande) را یک‌بار تنظیم می‌کند. - * اسناد جدید از webhook با اثرانگشت یکتا اعمال می‌شوند — بدون replay انبوه لاگ‌های قدیمی. + * اضطراری: اعمال اسناد روی کیف به‌طور کامل خاموش است تا از شارژ چندباره جلوگیری شود. + * دکمهٔ ادمین فقط مانده را (در صورت فعال بودن) یک‌بار تنظیم می‌کند. */ class PargarInboundWalletService { public const TX_TYPE_PARGAR = 22; - /** @var array */ - private array $seenFingerprints = []; + /** کلید کش برای قطع اضطراری هرگونه اعمال کیف از پرگار */ + public const KILL_SWITCH_CACHE_KEY = 'pargar_wallet_apply_disabled'; public function __construct( private readonly PargarSyncService $sync, @@ -28,6 +28,22 @@ class PargarInboundWalletService ) { } + public static function isApplyFrozen(): bool + { + // پیش‌فرض: یخ‌زده — مگر صریحاً false شده باشد + $cached = Cache::get(self::KILL_SWITCH_CACHE_KEY); + if ($cached === null) { + return true; + } + + return (bool) $cached; + } + + public static function freezeApplies(bool $frozen = true): void + { + Cache::forever(self::KILL_SWITCH_CACHE_KEY, $frozen); + } + /** * @param array $data * @return array{applied: int, skipped: int, errors: list, logs: list} @@ -35,115 +51,21 @@ class PargarInboundWalletService public function applyDocuments(Customer $customer, array $data, bool $broadcast = true): array { $transactions = is_array($data['transactions'] ?? null) ? $data['transactions'] : []; - $accountId = trim((string) ($data['accountId'] ?? '')); - $result = [ + Log::warning('Pargar applyDocuments blocked (frozen)', [ + 'customer_id' => $customer->id, + 'tx_count' => count($transactions), + ]); + + return [ 'applied' => 0, - 'skipped' => 0, - 'errors' => [], + 'skipped' => count($transactions), + 'errors' => ['اعمال اسناد پرگار روی کیف موقتاً غیرفعال است'], 'logs' => [], ]; - - if ($transactions === []) { - return $result; - } - - $lock = Cache::lock('pargar-wallet-apply:'.$customer->id, 60); - if (! $lock->get()) { - $result['errors'][] = 'همگام‌سازی دیگری برای این مشتری در حال اجراست'; - - return $result; - } - - try { - $walletApplied = false; - - foreach ($transactions as $tx) { - if (! is_array($tx)) { - continue; - } - - $fingerprint = $this->transactionFingerprint($accountId, $tx); - if ($fingerprint === '' || $this->isFingerprintApplied($fingerprint)) { - $result['skipped']++; - continue; - } - - // علامت در حافظه تا در همین درخواست دوباره نخورد - $this->seenFingerprints[$fingerprint] = true; - - $goldDelta = $this->transactionGoldDelta($tx); - $rialDelta = $this->transactionRialDelta($tx); - - if (abs($goldDelta) < 0.01 && abs($rialDelta) < 1) { - $this->recordProcessedFingerprint($customer, $fingerprint, $accountId, $tx, false, 'zero_amount'); - $result['skipped']++; - continue; - } - - if ($this->isEchoOfRecentOutbound((int) $customer->id, $goldDelta, $rialDelta)) { - $this->recordProcessedFingerprint($customer, $fingerprint, $accountId, $tx, false, 'echo_outbound'); - $result['skipped']++; - continue; - } - - try { - $this->applyTransactionDelta($customer, $tx, $goldDelta, $rialDelta); - $walletApplied = true; - - $summary = $this->sync->summarizeInboundDocument([ - 'accountId' => $accountId, - 'accountName' => $data['accountName'] ?? null, - 'transactions' => [$tx], - ]); - - $log = PargarSyncLog::create([ - 'customer_id' => $customer->id, - 'amount' => $summary['amount'], - 'desc' => $summary['desc'] !== '' ? $summary['desc'] : 'اعمال سند پرگار', - 'type' => PargarSyncService::TYPE_DOCUMENT, - 'sync_status' => PargarSyncService::STATUS_SUCCESS, - 'pargar_ref' => $fingerprint, - 'data' => json_encode([ - 'accountId' => $accountId, - 'accountName' => $data['accountName'] ?? null, - 'transaction' => $tx, - 'wallet_applied' => true, - 'fingerprint' => $fingerprint, - 'gold_delta' => $goldDelta, - 'rial_delta' => $rialDelta, - ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), - ]); - - $result['applied']++; - $result['logs'][] = (int) $log->id; - } catch (\Throwable $e) { - unset($this->seenFingerprints[$fingerprint]); - Log::warning('Pargar inbound document apply failed', [ - 'customer_id' => $customer->id, - 'fingerprint' => $fingerprint, - 'error' => $e->getMessage(), - ]); - $result['errors'][] = 'سند: '.$e->getMessage(); - } - } - - if ($walletApplied && $broadcast) { - $this->walletBroadcast->broadcastForCustomer( - (int) $customer->id, - (int) ($customer->type ?? 2) - ); - } - } finally { - $lock->release(); - } - - return $result; } /** - * دکمهٔ ادمین: فقط اختلاف ماندهٔ پرگار را یک‌بار اعمال می‌کند (نه replay اسناد). - * * @return array{ * documents: array{applied: int, skipped: int, errors: list, logs: list}, * mande: array{applied: bool, gold_delta?: float, rial_delta?: float, message: string} @@ -151,6 +73,16 @@ class PargarInboundWalletService */ public function syncWalletFromPargar(Customer $customer, bool $forceMande = false): array { + if (self::isApplyFrozen() && ! $forceMande) { + return [ + 'documents' => ['applied' => 0, 'skipped' => 0, 'errors' => [], 'logs' => []], + 'mande' => [ + 'applied' => false, + 'message' => 'اعمال کیف از پرگار یخ‌زده است — ابتدا با دستور pargar:wallet-freeze off آزاد کنید', + ], + ]; + } + $lock = Cache::lock('pargar-wallet-sync:'.$customer->id, 90); if (! $lock->get()) { return [ @@ -160,7 +92,6 @@ class PargarInboundWalletService } try { - // عمداً replay اسناد قدیمی نداریم — باعث شارژ چندباره می‌شد return [ 'documents' => ['applied' => 0, 'skipped' => 0, 'errors' => [], 'logs' => []], 'mande' => $this->applyLatestMandeDifference($customer, force: $forceMande), @@ -175,6 +106,13 @@ class PargarInboundWalletService */ public function applyLatestMandeDifference(Customer $customer, bool $force = false): array { + if (self::isApplyFrozen() && ! $force) { + return [ + 'applied' => false, + 'message' => 'اعمال کیف از پرگار یخ‌زده است', + ]; + } + if ($this->hasPendingOutboundWalletSync((int) $customer->id)) { return [ 'applied' => false, @@ -254,165 +192,6 @@ class PargarInboundWalletService ]; } - /** - * @param array $tx - */ - public function transactionFingerprint(string $accountId, array $tx): string - { - // بدون docId — اگر پرگار هر بار شناسهٔ جدید بدهد همان مبلغ دوباره نخورد - $parts = [ - $accountId, - trim((string) ($tx['date'] ?? '')), - trim((string) ($tx['docType'] ?? '')), - trim((string) ($tx['desc'] ?? '')), - $this->normalizeNumberString($tx['bedGold'] ?? 0), - $this->normalizeNumberString($tx['besGold'] ?? 0), - $this->normalizeNumberString($tx['bedRial'] ?? 0), - $this->normalizeNumberString($tx['besRial'] ?? 0), - ]; - - return 'fp:'.hash('sha256', implode('|', $parts)); - } - - public function isFingerprintApplied(string $fingerprint): bool - { - if ($fingerprint === '' || isset($this->seenFingerprints[$fingerprint])) { - return true; - } - - return PargarSyncLog::query() - ->where('type', PargarSyncService::TYPE_DOCUMENT) - ->where('pargar_ref', $fingerprint) - ->where('sync_status', PargarSyncService::STATUS_SUCCESS) - ->exists(); - } - - /** - * @param array $tx - */ - private function recordProcessedFingerprint( - Customer $customer, - string $fingerprint, - string $accountId, - array $tx, - bool $walletApplied, - string $reason - ): void { - if ($this->isFingerprintApplied($fingerprint) && ! isset($this->seenFingerprints[$fingerprint])) { - return; - } - - // اگر فقط در حافظه است و هنوز در DB نیست، برای skipها هم رکورد بساز تا تکرار نشود - if (PargarSyncLog::query() - ->where('type', PargarSyncService::TYPE_DOCUMENT) - ->where('pargar_ref', $fingerprint) - ->exists()) { - return; - } - - PargarSyncLog::create([ - 'customer_id' => $customer->id, - 'desc' => $walletApplied - ? 'اعمال سند پرگار' - : ($reason === 'echo_outbound' - ? 'سند پرگار — قبلاً از پنل ارسال شده (بدون اعمال مجدد)' - : 'سند پرگار — رد شده'), - 'type' => PargarSyncService::TYPE_DOCUMENT, - 'sync_status' => PargarSyncService::STATUS_SUCCESS, - 'pargar_ref' => $fingerprint, - 'data' => json_encode([ - 'accountId' => $accountId, - 'transaction' => $tx, - 'wallet_applied' => $walletApplied, - 'fingerprint' => $fingerprint, - 'skip_reason' => $walletApplied ? null : $reason, - ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), - ]); - } - - /** - * @param array $tx - */ - private function applyTransactionDelta(Customer $customer, array $tx, float $goldDelta, float $rialDelta): void - { - $ownerType = (int) ($customer->type ?? 2); - $walletInterface = new WalletInterface((int) $customer->id, $ownerType); - - $desc = trim((string) ($tx['desc'] ?? '')); - $docType = trim((string) ($tx['docType'] ?? '')); - $title = $desc !== '' ? $desc : ($docType !== '' ? $docType : 'سند پرگار'); - - $input = [ - 'title' => $title, - 'note' => trim((string) ($tx['docId'] ?? '')), - 'expire_at' => now(), - 'active' => true, - ]; - - if (abs($goldDelta) >= 0.01) { - $input['gold'] = abs($goldDelta); - } - if (abs($rialDelta) >= 1) { - $input['cash'] = abs($rialDelta); - } - - new TransactionInterface( - $walletInterface, - $input, - self::TX_TYPE_PARGAR, - $goldDelta > 0, - $rialDelta > 0 - ); - } - - /** - * @param array $tx - */ - private function transactionGoldDelta(array $tx): float - { - return $this->parseNumber($tx['besGold'] ?? 0) - $this->parseNumber($tx['bedGold'] ?? 0); - } - - /** - * @param array $tx - */ - private function transactionRialDelta(array $tx): float - { - return $this->parseNumber($tx['besRial'] ?? 0) - $this->parseNumber($tx['bedRial'] ?? 0); - } - - private function isEchoOfRecentOutbound(int $customerId, float $goldDelta, float $rialDelta): bool - { - $since = now()->subDays(3); - - $logs = PargarSyncLog::query() - ->where('customer_id', $customerId) - ->whereIn('type', [PargarSyncService::TYPE_WALLET_CASH, PargarSyncService::TYPE_WALLET_GOLD]) - ->where('created_at', '>=', $since) - ->whereIn('sync_status', [PargarSyncService::STATUS_PENDING, PargarSyncService::STATUS_SUCCESS]) - ->get(); - - foreach ($logs as $log) { - $payload = json_decode((string) $log->data, true) ?: []; - $signed = (float) ($payload['signed_amount'] ?? 0); - $kind = (string) ($payload['kind'] ?? ''); - - if ($kind === 'gold' && abs($goldDelta) >= 0.01 && abs(abs($signed) - abs($goldDelta)) < 0.01) { - if (($signed > 0 && $goldDelta > 0) || ($signed < 0 && $goldDelta < 0)) { - return true; - } - } - - if ($kind === 'cash' && abs($rialDelta) >= 1 && abs(abs($signed) - abs($rialDelta)) < 1) { - if (($signed > 0 && $rialDelta > 0) || ($signed < 0 && $rialDelta < 0)) { - return true; - } - } - } - - return false; - } - private function hasPendingOutboundWalletSync(int $customerId): bool { return PargarSyncLog::query() @@ -422,11 +201,6 @@ class PargarInboundWalletService ->exists(); } - private function normalizeNumberString(mixed $value): string - { - return (string) $this->parseNumber($value); - } - private function parseNumber(mixed $value): float { if ($value === null || $value === '') {