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 = MallPlayxOrder::where('grant_status', MallPlayxOrder::GRANT_ACCEPTED) ->where('status', MallPlayxOrder::STATUS_PENDING) ->order('id', 'desc') ->limit(50) ->select(); foreach ($list as $order) { /** @var MallPlayxOrder $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 === MallPlayxOrder::STATUS_COMPLETED) { $order->status = MallPlayxOrder::STATUS_COMPLETED; $order->save(); continue; } if ($pxStatus === 'FAILED' || $pxStatus === MallPlayxOrder::STATUS_REJECTED) { // 仅在从 PENDING 转 REJECTED 时退分,避免重复入账 $order->status = MallPlayxOrder::STATUS_REJECTED; $order->grant_status = MallPlayxOrder::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; } $bonusPath = strval(config('playx.api.bonus_grant_url', '/api/v1/bonus/grant')); $withdrawPath = strval(config('playx.api.balance_credit_url', '/api/v1/balance/credit')); $bonusUrl = rtrim($baseUrl, '/') . $bonusPath; $withdrawUrl = rtrim($baseUrl, '/') . $withdrawPath; $maxRetry = 3; $list = MallPlayxOrder::whereIn('grant_status', [ MallPlayxOrder::GRANT_NOT_SENT, MallPlayxOrder::GRANT_FAILED_RETRYABLE, ]) ->where('status', MallPlayxOrder::STATUS_PENDING) ->where('retry_count', '<', $maxRetry) ->order('id', 'desc') ->limit(50) ->select(); foreach ($list as $order) { /** @var MallPlayxOrder $order */ $allow = $this->allowRetryByInterval($order); if (!$allow) { continue; } $order->retry_count = intval($order->retry_count ?? 0) + 1; try { $this->sendGrantByOrder($order, $bonusUrl, $withdrawUrl, $maxRetry); } catch (\Throwable $e) { $order->fail_reason = $e->getMessage(); if (intval($order->retry_count) >= $maxRetry) { $order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL; $order->status = MallPlayxOrder::STATUS_REJECTED; $order->save(); $this->refundPoints($order); } else { $order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE; $order->save(); } } } } private function allowRetryByInterval(MallPlayxOrder $order): bool { if ($order->grant_status === MallPlayxOrder::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(MallPlayxOrder $order, string $bonusUrl, string $withdrawUrl, int $maxRetry): void { $item = null; if ($order->mallItem) { $item = $order->mallItem; } else { $item = MallItem::where('id', $order->mall_item_id)->find(); } if ($order->type === MallPlayxOrder::TYPE_BONUS) { $rewardName = $item ? strval($item->title) : ''; $category = $item ? strval($item->category) : 'daily'; $categoryTitle = $item ? strval($item->category_title) : ''; $multiplier = intval($order->multiplier ?? 0); if ($multiplier <= 0) { $multiplier = 1; } $requestId = 'mall_retry_bonus_' . uniqid(); $res = $this->http->post($bonusUrl, [ 'json' => [ 'request_id' => $requestId, 'externalTransactionId' => $order->external_transaction_id, 'user_id' => $order->user_id, 'amount' => $order->amount, 'rewardName' => $rewardName, 'category' => $category, 'categoryTitle' => $categoryTitle, 'multiplier' => $multiplier, ], ]); $data = json_decode(strval($res->getBody()), true) ?? []; if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') { $order->grant_status = MallPlayxOrder::GRANT_ACCEPTED; $order->playx_transaction_id = strval($data['playx_transaction_id'] ?? ''); $order->save(); return; } $order->fail_reason = strval($data['message'] ?? 'PlayX bonus grant not accepted'); if (intval($order->retry_count) >= $maxRetry) { $order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL; $order->status = MallPlayxOrder::STATUS_REJECTED; $order->save(); $this->refundPoints($order); return; } $order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE; $order->save(); return; } if ($order->type === MallPlayxOrder::TYPE_WITHDRAW) { $multiplier = intval($order->multiplier ?? 0); if ($multiplier <= 0) { $multiplier = 1; } $requestId = 'mall_retry_withdraw_' . uniqid(); $res = $this->http->post($withdrawUrl, [ 'json' => [ 'request_id' => $requestId, 'externalTransactionId' => $order->external_transaction_id, 'user_id' => $order->user_id, 'amount' => $order->amount, 'multiplier' => $multiplier, ], ]); $data = json_decode(strval($res->getBody()), true) ?? []; if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') { $order->grant_status = MallPlayxOrder::GRANT_ACCEPTED; $order->playx_transaction_id = strval($data['playx_transaction_id'] ?? ''); $order->save(); return; } $order->fail_reason = strval($data['message'] ?? 'PlayX balance credit not accepted'); if (intval($order->retry_count) >= $maxRetry) { $order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL; $order->status = MallPlayxOrder::STATUS_REJECTED; $order->save(); $this->refundPoints($order); return; } $order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE; $order->save(); return; } // PHYSICAL 目前由后台手工发货/驳回,不参与 PlayX 发放重试 } private function refundPoints(MallPlayxOrder $order): void { if ($order->points_cost <= 0) { return; } $asset = MallPlayxUserAsset::where('playx_user_id', $order->user_id)->find(); if (!$asset) { return; } // 已从用户可用积分中扣除,本次失败退回可用积分 $asset->available_points += intval($order->points_cost); $asset->save(); } }