From 5d8a0564b48290d576bcee9d551280ae04240a76 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Fri, 20 Mar 2026 18:11:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B9=E6=8D=AE=E5=AF=B9=E6=8E=A5=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E6=96=B9=E6=A1=88=E6=96=87=E6=A1=A3=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env-example | 15 + app/api/controller/v1/Playx.php | 700 ++++++++++++++++++ app/common/model/MallItem.php | 15 +- app/common/model/MallPlayxSession.php | 26 + app/process/PlayxJobs.php | 280 +++++++ config/playx.php | 26 + config/process.php | 6 + config/route.php | 11 + web/src/components/table/fieldRender/date.vue | 45 ++ web/src/lang/backend/en/mall/item.ts | 4 + web/src/lang/backend/zh-cn/mall/item.ts | 10 +- .../backend/channel/manage/whitelistPopup.vue | 10 +- web/src/views/backend/mall/item/index.vue | 41 +- web/src/views/backend/mall/item/popupForm.vue | 147 +++- 14 files changed, 1320 insertions(+), 16 deletions(-) create mode 100644 app/api/controller/v1/Playx.php create mode 100644 app/common/model/MallPlayxSession.php create mode 100644 app/process/PlayxJobs.php create mode 100644 config/playx.php create mode 100644 web/src/components/table/fieldRender/date.vue diff --git a/.env-example b/.env-example index 394412d..a9e61eb 100644 --- a/.env-example +++ b/.env-example @@ -16,3 +16,18 @@ DATABASE_PASSWORD = 123456 DATABASE_HOSTPORT = 3306 DATABASE_CHARSET = utf8mb4 DATABASE_PREFIX = + +# PlayX 配置 +# 提现折算:积分 -> 现金(如 10 分 = 1 元,则 points_to_cash_ratio=0.1) +PLAYX_POINTS_TO_CASH_RATIO=0.1 +# 返还比例:新增保障金 = ABS(yesterday_win_loss_net) * return_ratio(仅亏损时) +PLAYX_RETURN_RATIO=0.1 +# 解锁比例:今日可领取上限 = yesterday_total_deposit * unlock_ratio +PLAYX_UNLOCK_RATIO=0.1 +# Daily Push 签名校验密钥(建议从部署系统注入,避免写入代码/仓库) +PLAYX_DAILY_PUSH_SECRET= +# token 会话缓存过期时间(秒) +PLAYX_SESSION_EXPIRE_SECONDS=3600 +# PlayX API(商城调用 PlayX 时使用) +PLAYX_API_BASE_URL= +PLAYX_API_SECRET_KEY= diff --git a/app/api/controller/v1/Playx.php b/app/api/controller/v1/Playx.php new file mode 100644 index 0000000..af796d2 --- /dev/null +++ b/app/api/controller/v1/Playx.php @@ -0,0 +1,700 @@ +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(); + } + } +} diff --git a/app/common/model/MallItem.php b/app/common/model/MallItem.php index 895b96f..787de73 100644 --- a/app/common/model/MallItem.php +++ b/app/common/model/MallItem.php @@ -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'); diff --git a/app/common/model/MallPlayxSession.php b/app/common/model/MallPlayxSession.php new file mode 100644 index 0000000..3b4820e --- /dev/null +++ b/app/common/model/MallPlayxSession.php @@ -0,0 +1,26 @@ + 'integer', + 'update_time' => 'integer', + 'expire_time' => 'integer', + ]; +} + diff --git a/app/process/PlayxJobs.php b/app/process/PlayxJobs.php new file mode 100644 index 0000000..6b905ed --- /dev/null +++ b/app/process/PlayxJobs.php @@ -0,0 +1,280 @@ + 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(); + } +} + diff --git a/config/playx.php b/config/playx.php new file mode 100644 index 0000000..a837b80 --- /dev/null +++ b/config/playx.php @@ -0,0 +1,26 @@ + floatval(env('PLAYX_RETURN_RATIO', '0.1')), + // 解锁比例:今日可领取上限 = yesterday_total_deposit * 解锁比例 + 'unlock_ratio' => floatval(env('PLAYX_UNLOCK_RATIO', '0.1')), + // 提现折算:积分 → 现金(如 10 分 = 1 元) + 'points_to_cash_ratio' => floatval(env('PLAYX_POINTS_TO_CASH_RATIO', '0.1')), + // Daily Push 签名校验(PlayX 调用商城时使用) + 'daily_push_secret' => strval(env('PLAYX_DAILY_PUSH_SECRET', '')), + // token 会话缓存过期时间(秒) + 'session_expire_seconds' => intval(env('PLAYX_SESSION_EXPIRE_SECONDS', '3600')), + // PlayX API 配置(商城调用 PlayX 时使用) + 'api' => [ + 'base_url' => strval(env('PLAYX_API_BASE_URL', '')), + 'secret_key' => strval(env('PLAYX_API_SECRET_KEY', '')), + 'token_verify_url' => '/api/v1/auth/verify-token', + 'bonus_grant_url' => '/api/v1/bonus/grant', + 'balance_credit_url' => '/api/v1/balance/credit', + 'transaction_status_url' => '/api/v1/transaction/status', + ], +]; diff --git a/config/process.php b/config/process.php index ec14d25..4d3f349 100644 --- a/config/process.php +++ b/config/process.php @@ -59,4 +59,10 @@ return [ ] ] ] + , + // PlayX 闭环任务:轮询交易终态/失败重试 + 'playx_jobs' => [ + 'handler' => app\process\PlayxJobs::class, + 'reloadable' => false, + ], ]; diff --git a/config/route.php b/config/route.php index 1d306ff..e67780c 100644 --- a/config/route.php +++ b/config/route.php @@ -111,6 +111,17 @@ Route::post('/api/ems/send', [\app\api\controller\Ems::class, 'send']); // api/v1 鉴权 Route::get('/api/v1/authToken', [\app\api\controller\v1\Auth::class, 'authToken']); +// api/v1 PlayX 积分商城 +Route::post('/api/v1/playx/daily-push', [\app\api\controller\v1\Playx::class, 'dailyPush']); +Route::post('/api/v1/playx/verify-token', [\app\api\controller\v1\Playx::class, 'verifyToken']); +Route::get('/api/v1/playx/assets', [\app\api\controller\v1\Playx::class, 'assets']); +Route::post('/api/v1/playx/claim', [\app\api\controller\v1\Playx::class, 'claim']); +Route::get('/api/v1/playx/items', [\app\api\controller\v1\Playx::class, 'items']); +Route::post('/api/v1/playx/bonus/redeem', [\app\api\controller\v1\Playx::class, 'bonusRedeem']); +Route::post('/api/v1/playx/physical/redeem', [\app\api\controller\v1\Playx::class, 'physicalRedeem']); +Route::post('/api/v1/playx/withdraw/apply', [\app\api\controller\v1\Playx::class, 'withdrawApply']); +Route::get('/api/v1/playx/orders', [\app\api\controller\v1\Playx::class, 'orders']); + // ==================== Admin 路由 ==================== // Admin 多为 JSON API,前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容 diff --git a/web/src/components/table/fieldRender/date.vue b/web/src/components/table/fieldRender/date.vue new file mode 100644 index 0000000..91a17f8 --- /dev/null +++ b/web/src/components/table/fieldRender/date.vue @@ -0,0 +1,45 @@ + + + + diff --git a/web/src/lang/backend/en/mall/item.ts b/web/src/lang/backend/en/mall/item.ts index c99901e..b6f41d0 100644 --- a/web/src/lang/backend/en/mall/item.ts +++ b/web/src/lang/backend/en/mall/item.ts @@ -8,6 +8,10 @@ export default { 'type 1': 'type 1', 'type 2': 'type 2', 'type 3': 'type 3', + amount: 'amount', + multiplier: 'multiplier', + category: 'category', + category_title: 'category_title', admin_id: 'admin_id', admin__username: 'username', image: 'show image', diff --git a/web/src/lang/backend/zh-cn/mall/item.ts b/web/src/lang/backend/zh-cn/mall/item.ts index eca916c..fe6539d 100644 --- a/web/src/lang/backend/zh-cn/mall/item.ts +++ b/web/src/lang/backend/zh-cn/mall/item.ts @@ -5,9 +5,13 @@ export default { remark: '备注', score: '兑换积分', type: '类型', - 'type 1': '奖励', - 'type 2': '充值', - 'type 3': '实物', + 'type 1': '红利(BONUS)', + 'type 2': '实物(PHYSICAL)', + 'type 3': '提现(WITHDRAW)', + amount: '现金面值', + multiplier: '流水倍数', + category: '红利业务类别', + category_title: '类别展示名', admin_id: '创建管理员', admin__username: '创建管理员', image: '展示图', diff --git a/web/src/views/backend/channel/manage/whitelistPopup.vue b/web/src/views/backend/channel/manage/whitelistPopup.vue index 28e4584..2eb9ca1 100644 --- a/web/src/views/backend/channel/manage/whitelistPopup.vue +++ b/web/src/views/backend/channel/manage/whitelistPopup.vue @@ -1,11 +1,5 @@