PaymentWarningService.php 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. <?php
  2. namespace App\Services;
  3. use App\Notification\TelegramBot;
  4. use App\Util;
  5. use Illuminate\Support\Facades\DB;
  6. use Illuminate\Support\Facades\Redis;
  7. class PaymentWarningService
  8. {
  9. private const SUCCESS_STATUS = 1;
  10. private const PAY_METHOD_NAMES = [
  11. '1' => 'cashapp',
  12. '2' => 'paypal',
  13. '4' => 'apple',
  14. '8' => 'google',
  15. ];
  16. private const CONSECUTIVE_FAIL_LIMIT = 20;
  17. private const RATE_WINDOW_SECONDS = 600;
  18. private const RATE_THRESHOLD = 30;
  19. private const RATE_MIN_COUNT = 20;
  20. private const ALERT_TTL_SECONDS = 600;
  21. public function checkAll(): int
  22. {
  23. $startTime = date('Y-m-d H:i:s', time() - self::RATE_WINDOW_SECONDS);
  24. $items = DB::connection('read')->table('agent.dbo.order')
  25. ->select('payment_code', 'order_title')
  26. ->where('created_at', '>=', $startTime)
  27. ->whereNotNull('payment_code')
  28. ->whereNotNull('order_title')
  29. ->where('payment_code', '<>', '')
  30. ->where('order_title', '<>', '')
  31. ->groupBy('payment_code', 'order_title')
  32. ->lock('with(nolock)')
  33. ->get();
  34. $count = 0;
  35. foreach ($items as $item) {
  36. $paymentChannel = (string) $item->payment_code;
  37. $payMethod = (string) $item->order_title;
  38. if ($this->checkCombination($paymentChannel, $payMethod)) {
  39. $count++;
  40. }
  41. }
  42. return $count;
  43. }
  44. private function checkCombination(string $paymentChannel, string $payMethod): bool
  45. {
  46. if ($paymentChannel === '' || $payMethod === '') {
  47. return false;
  48. }
  49. $key = md5($paymentChannel . '|' . $payMethod);
  50. $this->checkConsecutiveFailedOrders($key, $paymentChannel, $payMethod);
  51. $this->checkSuccessRate($key, $paymentChannel, $payMethod);
  52. return true;
  53. }
  54. private function checkConsecutiveFailedOrders(string $key, string $paymentChannel, string $payMethod): void
  55. {
  56. $orders = DB::connection('read')->table('agent.dbo.order')
  57. ->select('order_sn', 'user_id', 'pay_status', 'created_at')
  58. ->where('payment_code', $paymentChannel)
  59. ->where('order_title', $payMethod)
  60. ->orderBy('created_at', 'desc')
  61. ->lock('with(nolock)')
  62. ->limit(self::CONSECUTIVE_FAIL_LIMIT)
  63. ->get();
  64. if ($orders->count() < self::CONSECUTIVE_FAIL_LIMIT) {
  65. return;
  66. }
  67. foreach ($orders as $order) {
  68. if ((int) ($order->pay_status ?? 0) === self::SUCCESS_STATUS) {
  69. Redis::del($this->consecutiveAlertKey($key));
  70. return;
  71. }
  72. }
  73. if (Redis::get($this->consecutiveAlertKey($key))) {
  74. return;
  75. }
  76. Redis::setex($this->consecutiveAlertKey($key), self::ALERT_TTL_SECONDS, 1);
  77. $latestOrder = $orders->first();
  78. $message = sprintf(
  79. "%s 支付连续失败预警\n支付渠道: %s\n支付方式: %s\n支付方式值: %s\n连续未成功订单数: %d\n最新订单: %s\n最新用户ID: %s\n最新创建时间: %s",
  80. env('APP_ENV'),
  81. $paymentChannel,
  82. $this->payMethodName($payMethod),
  83. $payMethod,
  84. self::CONSECUTIVE_FAIL_LIMIT,
  85. $latestOrder->order_sn ?? '',
  86. $latestOrder->user_id ?? '',
  87. $latestOrder->created_at ?? ''
  88. );
  89. $this->send($message);
  90. }
  91. private function checkSuccessRate(string $key, string $paymentChannel, string $payMethod): void
  92. {
  93. $windowStart = date('Y-m-d H:i:s', time() - self::RATE_WINDOW_SECONDS);
  94. $windowEnd = date('Y-m-d H:i:s');
  95. $stats = DB::connection('read')->table('agent.dbo.order')
  96. ->selectRaw('count(1) as total, sum(case when pay_status = 1 then 1 else 0 end) as success_total')
  97. ->where('payment_code', $paymentChannel)
  98. ->where('order_title', $payMethod)
  99. ->where('created_at', '>=', $windowStart)
  100. ->where('created_at', '<=', $windowEnd)
  101. ->lock('with(nolock)')
  102. ->first();
  103. $total = (int) ($stats->total ?? 0);
  104. if ($total < $this->rateMinCount()) {
  105. return;
  106. }
  107. $successTotal = (int) ($stats->success_total ?? 0);
  108. $failedTotal = $total - $successTotal;
  109. $rate = round($successTotal * 100 / $total, 2);
  110. if ($rate >= self::RATE_THRESHOLD || Redis::get($this->rateAlertKey($key))) {
  111. return;
  112. }
  113. Redis::setex($this->rateAlertKey($key), self::ALERT_TTL_SECONDS, 1);
  114. $message = sprintf(
  115. "%s 支付成功率预警\n支付渠道: %s\n支付方式: %s\n支付方式值: %s\n统计窗口: %s ~ %s\n成功率: %s%%\n成功/总数: %d/%d\n失败数: %d",
  116. env('APP_ENV'),
  117. $paymentChannel,
  118. $this->payMethodName($payMethod),
  119. $payMethod,
  120. $windowStart,
  121. $windowEnd,
  122. $rate,
  123. $successTotal,
  124. $total,
  125. $failedTotal
  126. );
  127. $this->send($message);
  128. }
  129. private function rateMinCount(): int
  130. {
  131. return (int) env('PAYMENT_WARNING_RATE_MIN_COUNT', self::RATE_MIN_COUNT);
  132. }
  133. private function payMethodName(string $payMethod): string
  134. {
  135. return self::PAY_METHOD_NAMES[$payMethod] ?? $payMethod;
  136. }
  137. private function send(string $message): void
  138. {
  139. try {
  140. TelegramBot::getDefault()->sendMsgAndImportant($message);
  141. } catch (\Throwable $throwable) {
  142. Util::WriteLog('PaymentWarningService', $throwable->getMessage());
  143. }
  144. }
  145. private function consecutiveAlertKey(string $key): string
  146. {
  147. return 'payment_warning:consecutive:' . $key;
  148. }
  149. private function rateAlertKey(string $key): string
  150. {
  151. return 'payment_warning:success_rate:' . $key;
  152. }
  153. }