根据对接实施方案文档修改

This commit is contained in:
2026-03-20 18:11:49 +08:00
parent ed5665cb85
commit 5d8a0564b4
14 changed files with 1320 additions and 16 deletions

280
app/process/PlayxJobs.php Normal file
View File

@@ -0,0 +1,280 @@
<?php
namespace app\process;
use app\common\model\MallItem;
use app\common\model\MallPlayxOrder;
use app\common\model\MallPlayxUserAsset;
use GuzzleHttp\Client;
use Workerman\Timer;
use Workerman\Worker;
/**
* PlayX 积分商城闭环任务
* - 轮询交易终态ACCEPTED -> 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('user_id', $order->user_id)->find();
if (!$asset) {
return;
}
// 已从用户可用积分中扣除,本次失败退回可用积分
$asset->available_points += intval($order->points_cost);
$asset->save();
}
}