| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- <?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;
- }
- }
|