|
|
@@ -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;
|
|
|
+ }
|
|
|
+
|
|
|
+}
|