Tree 6 dienas atpakaļ
vecāks
revīzija
3f4ec68b1f

+ 19 - 0
app/Console/Commands/CheckPaymentWarning.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\PaymentWarningService;
+use Illuminate\Console\Command;
+
+class CheckPaymentWarning extends Command
+{
+    protected $signature = 'payment:check-warning';
+
+    protected $description = 'Check payment warning metrics';
+
+    public function handle()
+    {
+        $count = (new PaymentWarningService())->checkAll();
+        $this->info('checked_payment_combinations=' . $count);
+    }
+}

+ 3 - 0
app/Console/Kernel.php

@@ -4,6 +4,7 @@ namespace App\Console;
 
 use App\Console\Commands\CheckGooglePlayStore;
 use App\Console\Commands\CheckIosAppStore;
+use App\Console\Commands\CheckPaymentWarning;
 use App\Console\Commands\CheckStockModeNegative;
 use App\Console\Commands\DbQueue;
 use App\Console\Commands\OnlineReport;
@@ -40,6 +41,7 @@ class Kernel extends ConsoleKernel
         RecordUserScoreChangeStatistics::class,
         DecStock::class,
         CheckIosAppStore::class,
+        CheckPaymentWarning::class,
         CheckGooglePlayStore::class,
         OnlineReport::class,
         DbQueue::class,
@@ -67,6 +69,7 @@ class Kernel extends ConsoleKernel
         $schedule->command('RecordUserScoreChangeStatistics')->cron('03 0 * * * ')->description('用户金额变化明细按天按用户汇总');
         $schedule->command('superball:update-pool-stats')->everyMinute()->description('Superball 每分钟刷新奖池及展示统计');
         $schedule->command('online_report')->everyMinute()->description('每分钟统计曲线');
+        $schedule->command('payment:check-warning')->everyMinute()->description('Check payment warning metrics');
         $schedule->command('ios:check-app-store')->everyMinute()->description('每5分钟检测 iOS App Store 包是否下架');
         $schedule->command('google:check-play-store')->everyMinute()->description('每5分钟检测 Google Play 包是否下架');
 

+ 179 - 0
app/Services/PaymentWarningService.php

@@ -0,0 +1,179 @@
+<?php
+
+namespace App\Services;
+
+use App\Notification\TelegramBot;
+use App\Util;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Redis;
+
+class PaymentWarningService
+{
+    private const SUCCESS_STATUS = 1;
+    private const PAY_METHOD_NAMES = [
+        '1' => 'cashapp',
+        '2' => 'paypal',
+        '4' => 'apple',
+        '8' => 'google',
+    ];
+    private const CONSECUTIVE_FAIL_LIMIT = 20;
+    private const RATE_WINDOW_SECONDS = 600;
+    private const RATE_THRESHOLD = 30;
+    private const RATE_MIN_COUNT = 20;
+    private const ALERT_TTL_SECONDS = 600;
+
+    public function checkAll(): int
+    {
+        $startTime = date('Y-m-d H:i:s', time() - self::RATE_WINDOW_SECONDS);
+        $items = DB::connection('read')->table('agent.dbo.order')
+            ->select('payment_code', 'order_title')
+            ->where('created_at', '>=', $startTime)
+            ->whereNotNull('payment_code')
+            ->whereNotNull('order_title')
+            ->where('payment_code', '<>', '')
+            ->where('order_title', '<>', '')
+            ->groupBy('payment_code', 'order_title')
+            ->lock('with(nolock)')
+            ->get();
+
+        $count = 0;
+        foreach ($items as $item) {
+            $paymentChannel = (string) $item->payment_code;
+            $payMethod = (string) $item->order_title;
+            if ($this->checkCombination($paymentChannel, $payMethod)) {
+                $count++;
+            }
+        }
+
+        return $count;
+    }
+
+    private function checkCombination(string $paymentChannel, string $payMethod): bool
+    {
+        if ($paymentChannel === '' || $payMethod === '') {
+            return false;
+        }
+
+        $key = md5($paymentChannel . '|' . $payMethod);
+        $this->checkConsecutiveFailedOrders($key, $paymentChannel, $payMethod);
+        $this->checkSuccessRate($key, $paymentChannel, $payMethod);
+
+        return true;
+    }
+
+    private function checkConsecutiveFailedOrders(string $key, string $paymentChannel, string $payMethod): void
+    {
+        $orders = DB::connection('read')->table('agent.dbo.order')
+            ->select('order_sn', 'user_id', 'pay_status', 'created_at')
+            ->where('payment_code', $paymentChannel)
+            ->where('order_title', $payMethod)
+            ->orderBy('created_at', 'desc')
+            ->lock('with(nolock)')
+            ->limit(self::CONSECUTIVE_FAIL_LIMIT)
+            ->get();
+
+        if ($orders->count() < self::CONSECUTIVE_FAIL_LIMIT) {
+            return;
+        }
+
+        foreach ($orders as $order) {
+            if ((int) ($order->pay_status ?? 0) === self::SUCCESS_STATUS) {
+                Redis::del($this->consecutiveAlertKey($key));
+                return;
+            }
+        }
+
+        if (Redis::get($this->consecutiveAlertKey($key))) {
+            return;
+        }
+
+        Redis::setex($this->consecutiveAlertKey($key), self::ALERT_TTL_SECONDS, 1);
+        $latestOrder = $orders->first();
+        $message = sprintf(
+            "%s 支付连续失败预警\n支付渠道: %s\n支付方式: %s\n支付方式值: %s\n连续未成功订单数: %d\n最新订单: %s\n最新用户ID: %s\n最新创建时间: %s",
+            env('APP_ENV'),
+            $paymentChannel,
+            $this->payMethodName($payMethod),
+            $payMethod,
+            self::CONSECUTIVE_FAIL_LIMIT,
+            $latestOrder->order_sn ?? '',
+            $latestOrder->user_id ?? '',
+            $latestOrder->created_at ?? ''
+        );
+
+        $this->send($message);
+    }
+
+    private function checkSuccessRate(string $key, string $paymentChannel, string $payMethod): void
+    {
+        $windowStart = date('Y-m-d H:i:s', time() - self::RATE_WINDOW_SECONDS);
+        $windowEnd = date('Y-m-d H:i:s');
+        $stats = DB::connection('read')->table('agent.dbo.order')
+            ->selectRaw('count(1) as total, sum(case when pay_status = 1 then 1 else 0 end) as success_total')
+            ->where('payment_code', $paymentChannel)
+            ->where('order_title', $payMethod)
+            ->where('created_at', '>=', $windowStart)
+            ->where('created_at', '<=', $windowEnd)
+            ->lock('with(nolock)')
+            ->first();
+
+        $total = (int) ($stats->total ?? 0);
+        if ($total < $this->rateMinCount()) {
+            return;
+        }
+
+        $successTotal = (int) ($stats->success_total ?? 0);
+        $failedTotal = $total - $successTotal;
+        $rate = round($successTotal * 100 / $total, 2);
+        if ($rate >= self::RATE_THRESHOLD || Redis::get($this->rateAlertKey($key))) {
+            return;
+        }
+
+        Redis::setex($this->rateAlertKey($key), self::ALERT_TTL_SECONDS, 1);
+        $message = sprintf(
+            "%s 支付成功率预警\n支付渠道: %s\n支付方式: %s\n支付方式值: %s\n统计窗口: %s ~ %s\n成功率: %s%%\n成功/总数: %d/%d\n失败数: %d",
+            env('APP_ENV'),
+            $paymentChannel,
+            $this->payMethodName($payMethod),
+            $payMethod,
+            $windowStart,
+            $windowEnd,
+            $rate,
+            $successTotal,
+            $total,
+            $failedTotal
+        );
+
+        $this->send($message);
+    }
+
+    private function rateMinCount(): int
+    {
+        return (int) env('PAYMENT_WARNING_RATE_MIN_COUNT', self::RATE_MIN_COUNT);
+    }
+
+    private function payMethodName(string $payMethod): string
+    {
+        return self::PAY_METHOD_NAMES[$payMethod] ?? $payMethod;
+    }
+
+    private function send(string $message): void
+    {
+        try {
+            TelegramBot::getDefault()->sendMsgAndImportant($message);
+        } catch (\Throwable $throwable) {
+            Util::WriteLog('PaymentWarningService', $throwable->getMessage());
+        }
+    }
+
+    private function consecutiveAlertKey(string $key): string
+    {
+        return 'payment_warning:consecutive:' . $key;
+    }
+
+    private function rateAlertKey(string $key): string
+    {
+        return 'payment_warning:success_rate:' . $key;
+    }
+
+}