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

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

View File

@@ -0,0 +1,700 @@
<?php
declare(strict_types=1);
namespace app\api\controller\v1;
use app\common\controller\Api;
use app\common\model\MallItem;
use app\common\model\MallPlayxClaimLog;
use app\common\model\MallPlayxDailyPush;
use app\common\model\MallPlayxSession;
use app\common\model\MallPlayxOrder;
use app\common\model\MallPlayxUserAsset;
use support\think\Db;
use Webman\Http\Request;
use support\Response;
/**
* PlayX 积分商城 API
*/
class Playx extends Api
{
/**
* 从请求中解析 PlayX 会话用户ID优先 session_id其次 user_id
*/
private function resolveUserIdFromRequest(Request $request): ?string
{
$sessionId = strval($request->post('session_id', $request->get('session_id', '')));
if ($sessionId !== '') {
$session = MallPlayxSession::where('session_id', $sessionId)->find();
if (!$session) {
return null;
}
$expireTime = intval($session->expire_time ?? 0);
if ($expireTime <= time()) {
return null;
}
return strval($session->user_id ?? '');
}
$userId = strval($request->post('user_id', $request->get('user_id', '')));
if ($userId === '') {
return null;
}
return $userId;
}
/**
* Daily Push API - PlayX 调用商城接收 T+1 数据
* POST /api/v1/playx/daily-push
*/
public function dailyPush(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$body = $request->post();
if (empty($body)) {
$raw = $request->rawBody();
if ($raw) {
$body = json_decode($raw, true) ?? [];
}
}
$requestId = $body['request_id'] ?? '';
$date = $body['date'] ?? '';
$userId = $body['user_id'] ?? '';
$yesterdayWinLossNet = $body['yesterday_win_loss_net'] ?? 0;
$yesterdayTotalDeposit = $body['yesterday_total_deposit'] ?? 0;
if ($requestId === '' || $date === '' || $userId === '') {
return $this->error(__('Missing required fields: request_id, date, user_id'));
}
$secret = config('playx.daily_push_secret', '');
if ($secret !== '') {
$sig = $request->header('X-Signature', '');
$ts = $request->header('X-Timestamp', '');
$rid = $request->header('X-Request-Id', '');
if ($sig === '' || $ts === '' || $rid === '') {
return $this->error('INVALID_SIGNATURE', null, 0, ['statusCode' => 401]);
}
$canonical = $ts . "\n" . $rid . "\nPOST\n/api/v1/playx/daily-push\n" . hash('sha256', json_encode($body));
$expected = hash_hmac('sha256', $canonical, $secret);
if (!hash_equals($expected, $sig)) {
return $this->error('INVALID_SIGNATURE', null, 0, ['statusCode' => 401]);
}
}
$exists = MallPlayxDailyPush::where('user_id', $userId)->where('date', $date)->find();
if ($exists) {
return $this->success('', [
'request_id' => $requestId,
'accepted' => true,
'deduped' => true,
'message' => 'duplicate input',
]);
}
Db::startTrans();
try {
MallPlayxDailyPush::create([
'user_id' => $userId,
'date' => $date,
'username' => $body['username'] ?? '',
'yesterday_win_loss_net' => $yesterdayWinLossNet,
'yesterday_total_deposit' => $yesterdayTotalDeposit,
'lifetime_total_deposit' => $body['lifetime_total_deposit'] ?? 0,
'lifetime_total_withdraw' => $body['lifetime_total_withdraw'] ?? 0,
'create_time' => time(),
]);
$returnRatio = config('playx.return_ratio', 0.1);
$unlockRatio = config('playx.unlock_ratio', 0.1);
$newLocked = 0;
if ($yesterdayWinLossNet < 0) {
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
}
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
$todayLimitDate = $date;
if ($asset) {
if ($asset->today_limit_date !== $todayLimitDate) {
$asset->today_claimed = 0;
$asset->today_limit_date = $todayLimitDate;
}
$asset->locked_points += $newLocked;
$asset->today_limit = $todayLimit;
$asset->username = $body['username'] ?? $asset->username;
$asset->save();
} else {
MallPlayxUserAsset::create([
'user_id' => $userId,
'username' => $body['username'] ?? '',
'locked_points' => $newLocked,
'available_points' => 0,
'today_limit' => $todayLimit,
'today_claimed' => 0,
'today_limit_date' => $todayLimitDate,
'create_time' => time(),
'update_time' => time(),
]);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('', [
'request_id' => $requestId,
'accepted' => true,
'deduped' => false,
'message' => 'ok',
]);
}
/**
* Token 验证 - 接收前端 token调用 PlayX 验证(占位,待 PlayX 提供 API
* POST /api/v1/playx/verify-token
*/
public function verifyToken(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$token = $request->post('token', $request->post('session', ''));
if ($token === '') {
return $this->error('INVALID_TOKEN', null, 0, ['statusCode' => 401]);
}
$baseUrl = config('playx.api.base_url', '');
$verifyUrl = config('playx.api.token_verify_url', '/api/v1/auth/verify-token');
if ($baseUrl === '') {
return $this->error('PlayX API not configured');
}
try {
$client = new \GuzzleHttp\Client([
'base_uri' => rtrim($baseUrl, '/') . '/',
'timeout' => 10,
]);
$res = $client->post($verifyUrl, [
'json' => [
'request_id' => 'mall_' . uniqid(),
'token' => $token,
],
]);
$code = $res->getStatusCode();
$data = json_decode(strval($res->getBody()), true);
if ($code !== 200 || empty($data['user_id'])) {
return $this->error($data['message'] ?? 'INVALID_TOKEN', null, 0, ['statusCode' => 401]);
}
$userId = strval($data['user_id']);
$username = strval($data['username'] ?? '');
$expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
if (!empty($data['token_expire_at'])) {
$ts = strtotime(strval($data['token_expire_at']));
if ($ts !== false && $ts > 0) {
$expireAt = intval($ts);
}
}
$sessionId = bin2hex(random_bytes(16));
MallPlayxSession::create([
'session_id' => $sessionId,
'user_id' => $userId,
'username' => $username,
'expire_time' => $expireAt,
'create_time' => time(),
'update_time' => time(),
]);
return $this->success('', [
'session_id' => $sessionId,
'user_id' => $userId,
'username' => $username,
'token_expire_at' => date('c', $expireAt),
]);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), null, 0, ['statusCode' => 500]);
}
}
/**
* 用户资产
* GET /api/v1/playx/assets?user_id=xxx
*/
public function assets(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$userId = $this->resolveUserIdFromRequest($request);
if ($userId === null) {
return $this->error('INVALID_TOKEN', null, 0, ['statusCode' => 401]);
}
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
if (!$asset) {
return $this->success('', [
'locked_points' => 0,
'available_points' => 0,
'today_limit' => 0,
'today_claimed' => 0,
'withdrawable_cash' => 0,
]);
}
$ratio = config('playx.points_to_cash_ratio', 0.1);
$withdrawableCash = round($asset->available_points * $ratio, 2);
return $this->success('', [
'locked_points' => $asset->locked_points,
'available_points' => $asset->available_points,
'today_limit' => $asset->today_limit,
'today_claimed' => $asset->today_claimed,
'withdrawable_cash' => $withdrawableCash,
]);
}
/**
* 领取
* POST /api/v1/playx/claim
*/
public function claim(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$claimRequestId = strval($request->post('claim_request_id', ''));
$userId = $this->resolveUserIdFromRequest($request);
if ($claimRequestId === '' || $userId === null) {
return $this->error(__('claim_request_id and user_id/session_id required'));
}
$exists = MallPlayxClaimLog::where('claim_request_id', $claimRequestId)->find();
if ($exists) {
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
return $this->success('', $this->formatAsset($asset));
}
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
if (!$asset) {
return $this->error(__('User asset not found'));
}
$todayLimitDate = date('Y-m-d');
if ($asset->today_limit_date !== $todayLimitDate) {
$asset->today_claimed = 0;
$asset->today_limit_date = $todayLimitDate;
}
$remain = $asset->today_limit - $asset->today_claimed;
if ($asset->locked_points <= 0 || $remain <= 0) {
return $this->error(__('No points to claim or limit reached'));
}
$canClaim = min($asset->locked_points, $remain);
Db::startTrans();
try {
MallPlayxClaimLog::create([
'claim_request_id' => $claimRequestId,
'user_id' => $userId,
'claimed_amount' => $canClaim,
'create_time' => time(),
]);
$asset->locked_points -= $canClaim;
$asset->available_points += $canClaim;
$asset->today_claimed += $canClaim;
$asset->save();
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
$asset->refresh();
return $this->success(__('Claim success'), $this->formatAsset($asset));
}
/**
* 商品列表
* GET /api/v1/playx/items?type=BONUS|PHYSICAL|WITHDRAW
*/
public function items(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$type = $request->get('type', '');
$typeMap = ['BONUS' => 1, 'PHYSICAL' => 2, 'WITHDRAW' => 3];
$query = MallItem::where('status', 1);
if ($type !== '' && isset($typeMap[$type])) {
$query->where('type', $typeMap[$type]);
}
$list = $query->order('sort', 'asc')->select();
return $this->success('', ['list' => $list->toArray()]);
}
/**
* 红利兑换
* POST /api/v1/playx/bonus/redeem
*/
public function bonusRedeem(Request $request): Response
{
return $this->redeemBonus($request);
}
/**
* 实物兑换
* POST /api/v1/playx/physical/redeem
*/
public function physicalRedeem(Request $request): Response
{
return $this->redeemPhysical($request);
}
/**
* 提现申请
* POST /api/v1/playx/withdraw/apply
*/
public function withdrawApply(Request $request): Response
{
return $this->redeemWithdraw($request);
}
/**
* 订单列表
* GET /api/v1/playx/orders?user_id=xxx
*/
public function orders(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$userId = $this->resolveUserIdFromRequest($request);
if ($userId === null) {
return $this->error('INVALID_TOKEN', null, 0, ['statusCode' => 401]);
}
$list = MallPlayxOrder::where('user_id', $userId)
->with(['mallItem'])
->order('id', 'desc')
->limit(100)
->select();
return $this->success('', ['list' => $list->toArray()]);
}
private function formatAsset(?MallPlayxUserAsset $asset): array
{
if (!$asset) {
return [
'locked_points' => 0,
'available_points' => 0,
'today_limit' => 0,
'today_claimed' => 0,
'withdrawable_cash' => 0,
];
}
$ratio = config('playx.points_to_cash_ratio', 0.1);
return [
'locked_points' => $asset->locked_points,
'available_points' => $asset->available_points,
'today_limit' => $asset->today_limit,
'today_claimed' => $asset->today_claimed,
'withdrawable_cash' => round($asset->available_points * $ratio, 2),
];
}
private function redeemBonus(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$itemId = intval($request->post('item_id', 0));
$userId = $this->resolveUserIdFromRequest($request);
if ($itemId <= 0 || $userId === null) {
return $this->error(__('item_id and user_id/session_id required'));
}
$item = MallItem::where('id', $itemId)->where('type', MallItem::TYPE_BONUS)->where('status', 1)->find();
if (!$item) {
return $this->error(__('Item not found or not available'));
}
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
if (!$asset || $asset->available_points < $item->score) {
return $this->error(__('Insufficient points'));
}
$multiplier = intval($item->multiplier ?? 0);
if ($multiplier <= 0) {
$multiplier = 1;
}
$amount = floatval($item->amount ?? 0);
Db::startTrans();
try {
$asset->available_points -= $item->score;
$asset->save();
$orderNo = 'BONUS_ORD' . date('YmdHis') . mt_rand(1000, 9999);
$order = MallPlayxOrder::create([
'user_id' => $userId,
'type' => MallPlayxOrder::TYPE_BONUS,
'status' => MallPlayxOrder::STATUS_PENDING,
'mall_item_id' => $item->id,
'points_cost' => $item->score,
'amount' => $amount,
'multiplier' => $multiplier,
'external_transaction_id' => $orderNo,
'grant_status' => MallPlayxOrder::GRANT_NOT_SENT,
'create_time' => time(),
'update_time' => time(),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
$baseUrl = config('playx.api.base_url', '');
if ($baseUrl !== '') {
$this->callPlayxBonusGrant($order, $item, $userId);
}
return $this->success(__('Redeem submitted, please wait about 10 minutes'), [
'order_id' => $order->id,
'status' => 'PENDING',
]);
}
private function redeemPhysical(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$itemId = intval($request->post('item_id', 0));
$userId = $this->resolveUserIdFromRequest($request);
$receiverName = $request->post('receiver_name', '');
$receiverPhone = $request->post('receiver_phone', '');
$receiverAddress = $request->post('receiver_address', '');
if ($itemId <= 0 || $userId === null || $receiverName === '' || $receiverPhone === '' || $receiverAddress === '') {
return $this->error(__('Missing required fields'));
}
$item = MallItem::where('id', $itemId)->where('type', MallItem::TYPE_PHYSICAL)->where('status', 1)->find();
if (!$item) {
return $this->error(__('Item not found or not available'));
}
if (isset($item->stock) && $item->stock < 1) {
return $this->error(__('Out of stock'));
}
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
if (!$asset || $asset->available_points < $item->score) {
return $this->error(__('Insufficient points'));
}
Db::startTrans();
try {
$asset->available_points -= $item->score;
$asset->save();
MallPlayxOrder::create([
'user_id' => $userId,
'type' => MallPlayxOrder::TYPE_PHYSICAL,
'status' => MallPlayxOrder::STATUS_PENDING,
'mall_item_id' => $item->id,
'points_cost' => $item->score,
'receiver_name' => $receiverName,
'receiver_phone' => $receiverPhone,
'receiver_address' => $receiverAddress,
'create_time' => time(),
'update_time' => time(),
]);
if (isset($item->stock)) {
$item->stock -= 1;
$item->save();
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success(__('Redeem success'));
}
private function redeemWithdraw(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$itemId = intval($request->post('item_id', 0));
$userId = $this->resolveUserIdFromRequest($request);
if ($itemId <= 0 || $userId === null) {
return $this->error(__('item_id and user_id/session_id required'));
}
$item = MallItem::where('id', $itemId)->where('type', MallItem::TYPE_WITHDRAW)->where('status', 1)->find();
if (!$item) {
return $this->error(__('Item not found or not available'));
}
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
if (!$asset || $asset->available_points < $item->score) {
return $this->error(__('Insufficient points'));
}
$multiplier = intval($item->multiplier ?? 0);
if ($multiplier <= 0) {
$multiplier = 1;
}
$amount = floatval($item->amount ?? 0);
Db::startTrans();
try {
$asset->available_points -= $item->score;
$asset->save();
$orderNo = 'WITHDRAW_ORD' . date('YmdHis') . mt_rand(1000, 9999);
$order = MallPlayxOrder::create([
'user_id' => $userId,
'type' => MallPlayxOrder::TYPE_WITHDRAW,
'status' => MallPlayxOrder::STATUS_PENDING,
'mall_item_id' => $item->id,
'points_cost' => $item->score,
'amount' => $amount,
'multiplier' => $multiplier,
'external_transaction_id' => $orderNo,
'grant_status' => MallPlayxOrder::GRANT_NOT_SENT,
'create_time' => time(),
'update_time' => time(),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
$baseUrl = config('playx.api.base_url', '');
if ($baseUrl !== '') {
$this->callPlayxBalanceCredit($order, $userId);
}
return $this->success(__('Withdraw submitted, please wait about 10 minutes'), [
'order_id' => $order->id,
'status' => 'PENDING',
]);
}
private function callPlayxBonusGrant(MallPlayxOrder $order, MallItem $item, string $userId): void
{
$baseUrl = rtrim(config('playx.api.base_url', ''), '/');
$url = config('playx.api.bonus_grant_url', '/api/v1/bonus/grant');
if ($baseUrl === '') {
return;
}
try {
$client = new \GuzzleHttp\Client(['timeout' => 15]);
$res = $client->post($baseUrl . $url, [
'json' => [
'request_id' => 'mall_bonus_' . uniqid(),
'externalTransactionId' => $order->external_transaction_id,
'user_id' => $userId,
'amount' => $order->amount,
'rewardName' => $item->title ?? '',
'category' => $item->category ?? 'daily',
'categoryTitle' => $item->category_title ?? '',
'multiplier' => $order->multiplier,
],
]);
$data = json_decode(strval($res->getBody()), true);
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
$order->playx_transaction_id = $data['playx_transaction_id'] ?? '';
$order->grant_status = MallPlayxOrder::GRANT_ACCEPTED;
$order->save();
} else {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $data['message'] ?? 'unknown';
$order->save();
}
} catch (\Throwable $e) {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $e->getMessage();
$order->save();
}
}
private function callPlayxBalanceCredit(MallPlayxOrder $order, string $userId): void
{
$baseUrl = rtrim(config('playx.api.base_url', ''), '/');
$url = config('playx.api.balance_credit_url', '/api/v1/balance/credit');
if ($baseUrl === '') {
return;
}
try {
$client = new \GuzzleHttp\Client(['timeout' => 15]);
$res = $client->post($baseUrl . $url, [
'json' => [
'request_id' => 'mall_withdraw_' . uniqid(),
'externalTransactionId' => $order->external_transaction_id,
'user_id' => $userId,
'amount' => $order->amount,
'multiplier' => $order->multiplier,
],
]);
$data = json_decode(strval($res->getBody()), true);
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
$order->playx_transaction_id = $data['playx_transaction_id'] ?? '';
$order->grant_status = MallPlayxOrder::GRANT_ACCEPTED;
$order->save();
} else {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $data['message'] ?? 'unknown';
$order->save();
}
} catch (\Throwable $e) {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $e->getMessage();
$order->save();
}
}
}

View File

@@ -2,20 +2,29 @@
namespace app\common\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
/**
* MallItem
* type: 1=BONUS(红利), 2=PHYSICAL(实物), 3=WITHDRAW(提现档位)
*/
class MallItem extends Model
{
use TimestampInteger;
protected $name = 'mall_item';
protected bool $autoWriteTimestamp = true;
public const TYPE_BONUS = 1;
public const TYPE_PHYSICAL = 2;
public const TYPE_WITHDRAW = 3;
protected array $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'amount' => 'float',
'multiplier' => 'integer',
];
public function admin(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use support\think\Model;
/**
* PlayX 会话缓存
*/
class MallPlayxSession extends Model
{
protected string $name = 'mall_playx_session';
protected bool $autoWriteTimestamp = true;
protected array $type = [
// 这里需要显式声明 create_time / update_time 为 integer
// 否则 ThinkORM 可能把 bigint 时间戳当成字符串,导致写入时出现 now 字符串问题。
'create_time' => 'integer',
'update_time' => 'integer',
'expire_time' => 'integer',
];
}

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();
}
}