243 lines
8.0 KiB
PHP
243 lines
8.0 KiB
PHP
<?php
|
||
|
||
namespace app\process;
|
||
|
||
use app\common\model\MallItem;
|
||
use app\common\model\MallOrder;
|
||
use app\common\model\MallUserAsset;
|
||
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 = 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;
|
||
}
|
||
|
||
$bonusPath = strval(config('playx.api.bonus_grant_url', '/api/v1/bonus/grant'));
|
||
$bonusUrl = rtrim($baseUrl, '/') . $bonusPath;
|
||
|
||
$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, $bonusUrl, $maxRetry);
|
||
} catch (\Throwable $e) {
|
||
$order->fail_reason = $e->getMessage();
|
||
if (intval($order->retry_count) >= $maxRetry) {
|
||
$order->grant_status = MallOrder::GRANT_FAILED_FINAL;
|
||
$order->status = MallOrder::STATUS_REJECTED;
|
||
$order->save();
|
||
$this->refundPoints($order);
|
||
} 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, string $bonusUrl, int $maxRetry): void
|
||
{
|
||
$item = null;
|
||
if ($order->mallItem) {
|
||
$item = $order->mallItem;
|
||
} else {
|
||
$item = MallItem::where('id', $order->mall_item_id)->find();
|
||
}
|
||
|
||
if ($order->type === MallOrder::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 = MallOrder::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 = MallOrder::GRANT_FAILED_FINAL;
|
||
$order->status = MallOrder::STATUS_REJECTED;
|
||
$order->save();
|
||
$this->refundPoints($order);
|
||
return;
|
||
}
|
||
|
||
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
|
||
$order->save();
|
||
return;
|
||
}
|
||
|
||
// 非 BONUS 订单不参与 PlayX 发放重试(提现/实物由后台流程处理)
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
|