post('session_id', $request->get('session_id', ''))); if ($sessionId !== '') { $session = MallPlayxSession::where('session_id', $sessionId)->find(); if ($session) { $expireTime = intval($session->expire_time ?? 0); if ($expireTime > time()) { $asset = MallPlayxUserAsset::where('playx_user_id', strval($session->user_id ?? ''))->find(); if ($asset) { return intval($asset->getKey()); } } } $assetId = $this->resolveAssetIdByToken($sessionId); if ($assetId !== null) { return $assetId; } } $token = strval($request->post('token', $request->get('token', ''))); if ($token === '') { $token = get_auth_token(['ba', 'token'], $request); } if ($token !== '') { return $this->resolveAssetIdByToken($token); } $userId = strval($request->post('user_id', $request->get('user_id', ''))); if ($userId === '') { return null; } if (ctype_digit($userId)) { return intval($userId); } $asset = MallPlayxUserAsset::where('playx_user_id', $userId)->find(); if ($asset) { return intval($asset->getKey()); } return null; } private function resolveAssetIdByToken(string $token): ?int { $tokenData = Token::get($token); $tokenType = strval($tokenData['type'] ?? ''); $isMemberOrMall = $tokenType === UserAuth::TOKEN_TYPE || $tokenType === UserAuth::TOKEN_TYPE_MALL_USER; if (!empty($tokenData) && $isMemberOrMall && intval($tokenData['expire_time'] ?? 0) > time() && intval($tokenData['user_id'] ?? 0) > 0 ) { return intval($tokenData['user_id']); } return null; } private function buildTempPhone(): ?string { for ($i = 0; $i < 8; $i++) { $candidate = '13' . str_pad(strval(mt_rand(0, 999999999)), 9, '0', STR_PAD_LEFT); if (!MallPlayxUserAsset::where('phone', $candidate)->find()) { return $candidate; } } return null; } private function ensureAssetForPlayx(string $playxUserId, string $username): ?MallPlayxUserAsset { $asset = MallPlayxUserAsset::where('playx_user_id', $playxUserId)->find(); if ($asset) { return $asset; } $effectiveUsername = trim($username); if ($effectiveUsername === '') { $effectiveUsername = 'playx_' . $playxUserId; } $byName = MallPlayxUserAsset::where('username', $effectiveUsername)->find(); if ($byName) { $byName->playx_user_id = $playxUserId; $byName->save(); return $byName; } $phone = $this->buildTempPhone(); if ($phone === null) { return null; } $pwd = hash_password(Random::build('alnum', 16)); $now = time(); return MallPlayxUserAsset::create([ 'playx_user_id' => $playxUserId, 'username' => $effectiveUsername, 'phone' => $phone, 'password' => $pwd, 'admin_id' => 0, 'locked_points' => 0, 'available_points' => 0, 'today_limit' => 0, 'today_claimed' => 0, 'today_limit_date' => null, 'create_time' => $now, 'update_time' => $now, ]); } private function getAssetById(int $assetId): ?MallPlayxUserAsset { return MallPlayxUserAsset::where('id', $assetId)->find(); } /** * 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'] ?? ''; $playxUserId = strval($body['user_id'] ?? ''); $yesterdayWinLossNet = $body['yesterday_win_loss_net'] ?? 0; $yesterdayTotalDeposit = $body['yesterday_total_deposit'] ?? 0; if ($requestId === '' || $date === '' || $playxUserId === '') { 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', $playxUserId)->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' => $playxUserId, '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 = $this->ensureAssetForPlayx($playxUserId, strval($body['username'] ?? '')); if (!$asset) { throw new \RuntimeException(__('Failed to map playx user to mall user')); } $todayLimitDate = $date; if ($asset->today_limit_date !== $todayLimitDate) { $asset->today_claimed = 0; $asset->today_limit_date = $todayLimitDate; } $asset->locked_points = intval($asset->locked_points ?? 0) + $newLocked; $asset->today_limit = $todayLimit; $asset->playx_user_id = $playxUserId; $uname = trim(strval($body['username'] ?? '')); if ($uname !== '') { $asset->username = $uname; } $asset->save(); 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 验证 - POST /api/v1/playx/verify-token * 配置 playx.verify_token_local_only=true 时仅本地校验 token(不请求 PlayX)。 */ public function verifyToken(Request $request): Response { $response = $this->initializeApi($request); if ($response !== null) { return $response; } $token = strval($request->post('token', $request->post('session', $request->get('token', '')))); if ($token === '') { return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); } if (config('playx.verify_token_local_only', false)) { return $this->verifyTokenLocal($token); } $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'])) { $remoteMsg = $data['message'] ?? ''; $msg = is_string($remoteMsg) && $remoteMsg !== '' ? $remoteMsg : __('Invalid token'); return $this->error($msg, 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]); } } /** * 本地校验 temLogin 等写入的商城 token(类型 muser),写入 mall_playx_session */ private function verifyTokenLocal(string $token): Response { $tokenData = Token::get($token); if (empty($tokenData) || (isset($tokenData['expire_time']) && intval($tokenData['expire_time']) <= time())) { return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); } $tokenType = strval($tokenData['type'] ?? ''); if ($tokenType !== UserAuth::TOKEN_TYPE_MALL_USER) { return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); } $assetId = intval($tokenData['user_id'] ?? 0); if ($assetId <= 0) { return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); } $asset = MallPlayxUserAsset::where('id', $assetId)->find(); if (!$asset) { return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); } $playxUserId = strval($asset->playx_user_id ?? ''); if ($playxUserId === '') { $playxUserId = strval($assetId); } $expireAt = time() + intval(config('playx.session_expire_seconds', 3600)); $sessionId = bin2hex(random_bytes(16)); MallPlayxSession::create([ 'session_id' => $sessionId, 'user_id' => $playxUserId, 'username' => strval($asset->username ?? ''), 'expire_time' => $expireAt, 'create_time' => time(), 'update_time' => time(), ]); return $this->success('', [ 'session_id' => $sessionId, 'user_id' => $playxUserId, 'username' => strval($asset->username ?? ''), 'token_expire_at' => date('c', $expireAt), ]); } /** * 用户资产 * GET /api/v1/playx/assets?user_id=xxx */ public function assets(Request $request): Response { $response = $this->initializeApi($request); if ($response !== null) { return $response; } $assetId = $this->resolvePlayxAssetIdFromRequest($request); if ($assetId === null) { return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); } $asset = $this->getAssetById($assetId); 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', '')); $assetId = $this->resolvePlayxAssetIdFromRequest($request); if ($claimRequestId === '' || $assetId === null) { return $this->error(__('claim_request_id and user_id/session_id required')); } $asset = $this->getAssetById($assetId); if (!$asset || strval($asset->playx_user_id ?? '') === '') { return $this->error(__('User asset not found')); } $playxUserId = strval($asset->playx_user_id); $exists = MallPlayxClaimLog::where('claim_request_id', $claimRequestId)->find(); if ($exists) { return $this->success('', $this->formatAsset($asset)); } $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' => $playxUserId, '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; } $assetId = $this->resolvePlayxAssetIdFromRequest($request); if ($assetId === null) { return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); } $asset = $this->getAssetById($assetId); if (!$asset || strval($asset->playx_user_id ?? '') === '') { return $this->success('', ['list' => []]); } $list = MallPlayxOrder::where('user_id', strval($asset->playx_user_id)) ->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)); $assetId = $this->resolvePlayxAssetIdFromRequest($request); if ($itemId <= 0 || $assetId === 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 = $this->getAssetById($assetId); if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) { return $this->error(__('Insufficient points')); } $playxUserId = strval($asset->playx_user_id); $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' => $playxUserId, '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, $playxUserId); } 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)); $assetId = $this->resolvePlayxAssetIdFromRequest($request); $receiverName = $request->post('receiver_name', ''); $receiverPhone = $request->post('receiver_phone', ''); $receiverAddress = $request->post('receiver_address', ''); if ($itemId <= 0 || $assetId === 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 = $this->getAssetById($assetId); if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) { return $this->error(__('Insufficient points')); } $playxUserId = strval($asset->playx_user_id); Db::startTrans(); try { $asset->available_points -= $item->score; $asset->save(); MallPlayxOrder::create([ 'user_id' => $playxUserId, '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)); $assetId = $this->resolvePlayxAssetIdFromRequest($request); if ($itemId <= 0 || $assetId === 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 = $this->getAssetById($assetId); if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) { return $this->error(__('Insufficient points')); } $playxUserId = strval($asset->playx_user_id); $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' => $playxUserId, '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, $playxUserId); } 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(); } } }