COMPLETED/REJECTED) * - 对可重试失败进行重发(依赖 external_transaction_id 幂等) */ class PlayxJobs { protected Client $http; public function __construct() { // 确保定时任务只在一个 worker 上运行 if (!Worker::getAllWorkers()) { return; } $this->http = new Client([ 'timeout' => 20, 'http_errors' => false, ]); Timer::add(60, [$this, 'pollTransactionStatus']); Timer::add(60, [$this, 'retryFailedGrants']); } /** * 轮询:已 accepted 的订单,查询终态 */ public function pollTransactionStatus(): void { $baseUrl = strval(config('playx.api.base_url', '')); if ($baseUrl === '') { return; } $path = strval(config('playx.api.transaction_status_url', '/api/v1/transaction/status')); $url = rtrim($baseUrl, '/') . $path; $list = MallOrder::where('type', MallOrder::TYPE_BONUS) ->where('grant_status', MallOrder::GRANT_ACCEPTED) ->where('status', MallOrder::STATUS_PENDING) ->order('id', 'desc') ->limit(50) ->select(); foreach ($list as $order) { /** @var MallOrder $order */ try { $res = $this->http->get($url, [ 'query' => [ 'externalTransactionId' => $order->external_transaction_id, ], ]); $data = json_decode(strval($res->getBody()), true) ?? []; $pxStatus = $data['status'] ?? ''; if ($pxStatus === MallOrder::STATUS_COMPLETED) { $order->status = MallOrder::STATUS_COMPLETED; $order->save(); continue; } if ($pxStatus === 'FAILED' || $pxStatus === MallOrder::STATUS_REJECTED) { // 仅在从 PENDING 转 REJECTED 时退分,避免重复入账 $order->status = MallOrder::STATUS_REJECTED; $order->grant_status = MallOrder::GRANT_FAILED_FINAL; $order->fail_reason = strval($data['message'] ?? 'PlayX transaction failed'); $order->save(); $this->refundPoints($order); continue; } // 其他情况视为仍在队列/PENDING,保持不变 } catch (\Throwable $e) { // 查询失败不影响状态,下一轮重试 } } } /** * 重试:NOT_SENT / FAILED_RETRYABLE(按 retry_count 间隔) */ public function retryFailedGrants(): void { $baseUrl = strval(config('playx.api.base_url', '')); if ($baseUrl === '') { return; } $maxRetry = 3; $list = MallOrder::where('type', MallOrder::TYPE_BONUS) ->whereIn('grant_status', [ MallOrder::GRANT_NOT_SENT, MallOrder::GRANT_FAILED_RETRYABLE, ]) ->where('status', MallOrder::STATUS_PENDING) ->where('retry_count', '<', $maxRetry) ->order('id', 'desc') ->limit(50) ->select(); foreach ($list as $order) { /** @var MallOrder $order */ $allow = $this->allowRetryByInterval($order); if (!$allow) { continue; } $order->retry_count = intval($order->retry_count ?? 0) + 1; try { $this->sendGrantByOrder($order, $maxRetry); } catch (\Throwable $e) { $order->fail_reason = $e->getMessage(); if (intval($order->retry_count) >= $maxRetry) { $order->grant_status = MallOrder::GRANT_FAILED_FINAL; $order->save(); } else { $order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE; $order->save(); } } } } private function allowRetryByInterval(MallOrder $order): bool { if ($order->grant_status === MallOrder::GRANT_NOT_SENT) { return true; } $retryCount = intval($order->retry_count ?? 0); $updatedAt = intval($order->update_time ?? 0); $diff = time() - $updatedAt; // retry_count: 已失败重试次数 // 0 -> 下次重试:等待 1min // 1 -> 下次重试:等待 5min // 2 -> 下次重试:等待 15min if ($retryCount === 0 && $diff >= 60) { return true; } if ($retryCount === 1 && $diff >= 300) { return true; } if ($retryCount >= 2 && $diff >= 900) { return true; } return false; } private function sendGrantByOrder(MallOrder $order, int $maxRetry): void { if ($order->type !== MallOrder::TYPE_BONUS) { return; } $result = MallBonusGrantPush::push($order); if ($result['ok']) { $order->grant_status = MallOrder::GRANT_ACCEPTED; $order->playx_transaction_id = $result['playx_transaction_id']; $order->save(); return; } $order->fail_reason = $result['message']; if (intval($order->retry_count) >= $maxRetry) { $order->grant_status = MallOrder::GRANT_FAILED_FINAL; $order->save(); return; } $order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE; $order->save(); } private function refundPoints(MallOrder $order): void { if ($order->points_cost <= 0) { return; } $asset = MallUserAsset::where('playx_user_id', $order->user_id)->find(); if (!$asset) { return; } // 已从用户可用积分中扣除,本次失败退回可用积分 $asset->available_points += intval($order->points_cost); $asset->save(); } }