'视讯', 2 => '老虎机', 3 => '彩票', 4 => '体育', 5 => '电竞', 6 => '捕猎', 7 => '棋牌', ]; private const ORDER_STATUS_NAMES = [ 0 => '未完成', 1 => '已完成', 2 => '已取消', 3 => '已撤单', ]; /** * 批量查询用户的三方游戏总余额,三方与主钱包金额按 1:1 处理。 * * @param array $memberIds * @return array */ public function totals(array $memberIds): array { $memberIds = array_values(array_unique(array_map('strval', $memberIds))); $totals = []; $pending = []; foreach ($memberIds as $memberId) { $cached = Cache::get($this->cacheKey($memberId)); if ($cached !== null) { $totals[$memberId] = (float) $cached; } else { $pending[] = $memberId; } } if ($pending === []) { return $totals; } if (!$this->configured()) { Log::warning('third_game_balance_config_missing'); foreach ($pending as $memberId) { $totals[$memberId] = null; } return $totals; } try { $responses = Http::pool(function (Pool $pool) use ($pending) { $requests = []; foreach ($pending as $memberId) { $random = $this->random(); $requests[] = $pool->as($memberId) ->asJson() ->withHeaders([ 'sign' => md5($random . config('third_game.sn') . config('third_game.key')), 'random' => $random, 'sn' => config('third_game.sn'), ]) ->timeout(max(1, (int) config('third_game.timeout', 10))) ->connectTimeout(5) ->withOptions($this->httpOptions()) ->post(config('third_game.api_url') . '/api/server/balanceAll', [ 'playerId' => $this->playerId($memberId), 'currency' => config('third_game.currency', 'CNY'), ]); } return $requests; }); } catch (\Throwable $e) { Log::warning('third_game_balance_pool_failed', ['message' => $e->getMessage()]); foreach ($pending as $memberId) { $totals[$memberId] = null; } return $totals; } foreach ($pending as $memberId) { $response = $responses[$memberId] ?? null; $balances = $this->balancesFromResponse($response); $total = $balances === null ? null : $this->totalFromBalances($balances); $totals[$memberId] = $total; if ($total !== null) { Cache::put( $this->cacheKey($memberId), $total, max(1, (int) config('third_game.cache_seconds', 30)) ); Cache::put( $this->detailCacheKey($memberId), $balances, max(1, (int) config('third_game.cache_seconds', 30)) ); } } return $totals; } public function playerId(string $memberId): string { return 'p' . substr(md5(config('third_game.sn') . '_' . $memberId), 0, 10); } /** * 查询单个用户在 ag、pg 等各游戏平台的余额。 */ public function detail(string $memberId, bool $forceRefresh = false): array { if (!$forceRefresh) { $cached = Cache::get($this->detailCacheKey($memberId)); if (is_array($cached)) { return $this->detailResult($memberId, $cached); } } $response = $this->request('/api/server/balanceAll', [ 'playerId' => $this->playerId($memberId), 'currency' => config('third_game.currency', 'CNY'), ]); $balances = $this->balancesFromResponse($response); if ($balances === null) { return ['ok' => false, 'msg' => $this->responseMessage($response)]; } $ttl = max(1, (int) config('third_game.cache_seconds', 30)); Cache::put($this->detailCacheKey($memberId), $balances, $ttl); Cache::put($this->cacheKey($memberId), $this->totalFromBalances($balances), $ttl); return $this->detailResult($memberId, $balances); } /** * 查询三方游戏历史订单,按订单更新时间正序返回。 */ public function historyOrders( string $startTime, string $endTime, int $page = 1, int $limit = 200 ): array { if (!$this->configured()) { return ['ok' => false, 'msg' => '三方游戏配置缺失']; } $rangeError = $this->historyRangeError($startTime, $endTime); if ($rangeError !== null) { return ['ok' => false, 'msg' => $rangeError]; } $rateError = $this->acquireHistoryQuota($startTime, $endTime, $page, $limit); if ($rateError !== null) { return ['ok' => false, 'msg' => $rateError]; } $response = $this->request('/api/server/recordHistory', [ 'currency' => (string) config('third_game.currency', 'CNY'), 'startTime' => $startTime, 'endTime' => $endTime, 'pageNo' => (string) $page, 'pageSize' => (string) $limit, ]); if (!$response instanceof Response) { return ['ok' => false, 'msg' => $this->responseMessage($response)]; } $body = $response->json(); if (!is_array($body) || (int) ($body['code'] ?? 0) !== self::CODE_SUCCESS) { return ['ok' => false, 'msg' => $this->responseMessage($response)]; } $this->rememberHistorySession($startTime, $endTime, $limit); $providerData = is_array($body['data'] ?? null) ? $body['data'] : []; $providerList = is_array($providerData['list'] ?? null) ? $providerData['list'] : []; $list = []; foreach ($providerList as $row) { if (is_array($row)) { $list[] = $this->formatOrder($row); } } $result = [ 'ok' => true, 'currency' => (string) config('third_game.currency', 'CNY'), 'start_time' => $startTime, 'end_time' => $endTime, 'total' => (int) ($providerData['total'] ?? count($list)), 'page_no' => (int) ($providerData['pageNo'] ?? $page), 'page_size' => (int) ($providerData['pageSize'] ?? $limit), 'list' => $list, ]; return $result; } /** * 获取最近10分钟(不含当前分钟)的实时游戏订单。 */ public function realtimeOrders(int $page = 1, int $limit = 2000): array { if (!$this->configured()) { return ['ok' => false, 'msg' => '三方游戏配置缺失']; } $rateError = $this->acquireRealtimeQuota($page, $limit); if ($rateError !== null) { return ['ok' => false, 'msg' => $rateError]; } $response = $this->request('/api/server/recordAll', [ 'currency' => (string) config('third_game.currency', 'CNY'), 'pageNo' => (string) $page, 'pageSize' => (string) $limit, ]); if (!$response instanceof Response) { return ['ok' => false, 'msg' => $this->responseMessage($response)]; } $body = $response->json(); if (!is_array($body) || (int) ($body['code'] ?? 0) !== self::CODE_SUCCESS) { return ['ok' => false, 'msg' => $this->responseMessage($response)]; } $this->rememberRealtimeSession($limit); $providerData = is_array($body['data'] ?? null) ? $body['data'] : []; $providerList = is_array($providerData['list'] ?? null) ? $providerData['list'] : []; $list = []; foreach ($providerList as $row) { if (is_array($row)) { $list[] = $this->formatOrder($row); } } return [ 'ok' => true, 'currency' => (string) config('third_game.currency', 'CNY'), 'total' => (int) ($providerData['total'] ?? count($list)), 'page_no' => (int) ($providerData['pageNo'] ?? $page), 'page_size' => (int) ($providerData['pageSize'] ?? $limit), 'list' => $list, ]; } /** * 一键把用户在所有三方游戏平台的余额转出。 */ public function recycle(string $memberId): array { // 必须在调用 transferAll 前校验,避免三方已转出后才因本地配置错误中断。 $this->recycleRate(); $response = $this->request('/api/server/transferAll', [ 'playerId' => $this->playerId($memberId), 'currency' => config('third_game.currency', 'CNY'), ], true); $body = $response instanceof Response ? $response->json() : null; if (!is_array($body)) { return [ 'ok' => false, 'uncertain' => true, 'msg' => $response instanceof Response ? '三方游戏接口返回异常,回收结果待核对' : '三方游戏接口请求失败,回收结果待核对', ]; } if ((int) ($body['code'] ?? 0) === self::CODE_SUCCESS) { $data = $body['data'] ?? null; if (!is_array($data) || !array_key_exists('balanceAll', $data) || !is_numeric($data['balanceAll']) || (float) $data['balanceAll'] < 0) { return [ 'ok' => false, 'uncertain' => true, 'msg' => '三方游戏回收成功响应缺少有效 balanceAll,回收结果待核对', ]; } $gameBalance = $this->decimalAmount($data['balanceAll']); if ($gameBalance === null) { return [ 'ok' => false, 'uncertain' => true, 'msg' => '三方游戏回收金额格式异常,回收结果待核对', ]; } $this->clearCache($memberId); return [ 'ok' => true, 'game_balance' => $gameBalance, 'wallet_balance' => $this->toWalletBalance($gameBalance), ]; } if ($this->isPlayerNotFound((string) ($body['msg'] ?? ''))) { $this->clearCache($memberId); return ['ok' => true, 'game_balance' => '0.0000000000', 'wallet_balance' => '0.0000000000']; } return [ 'ok' => false, 'uncertain' => false, 'msg' => $this->responseMessage($response), ]; } public function toWalletBalance($gameBalance): string { $balance = $this->decimalAmount($gameBalance); if ($balance === null) { throw new \InvalidArgumentException('三方游戏余额格式异常'); } return bcdiv($balance, $this->recycleRate(), 10); } private function recycleRate(): string { $rate = trim((string) config('third_game.recycle_rate', '1')); if (!preg_match('/^\d+(?:\.\d+)?$/D', $rate) || bccomp($rate, '0', 10) <= 0) { throw new \RuntimeException('GAME_RECYCLE_RATE 必须是大于 0 的数字'); } return $rate; } private function balancesFromResponse($response): ?array { if (!$response instanceof Response) { return null; } $body = $response->json(); if (!is_array($body)) { return null; } if ((int) ($body['code'] ?? 0) === self::CODE_SUCCESS) { return is_array($body['data'] ?? null) ? $body['data'] : []; } if ($this->isPlayerNotFound((string) ($body['msg'] ?? ''))) { return []; } Log::warning('third_game_balance_query_failed', [ 'code' => $body['code'] ?? null, 'msg' => $body['msg'] ?? null, ]); return null; } private function detailResult(string $memberId, array $balances): array { $gameBalance = $this->sumBalances($balances); $walletBalances = []; $gameBalances = []; foreach ($balances as $platform => $balance) { if (is_numeric($balance)) { $walletBalances[] = [ 'platform' => (string) $platform, 'balance' => (float) $balance, ]; $gameBalances[] = [ 'platform' => (string) $platform, 'balance' => (float) $balance, ]; } } return [ 'ok' => true, 'member_id' => $memberId, 'player_id' => $this->playerId($memberId), 'currency' => (string) config('third_game.currency', 'CNY'), 'rate' => 1, 'list' => $walletBalances, 'game_list' => $gameBalances, 'total_game_balance' => (float) $gameBalance, 'total_balance' => $this->totalFromBalances($balances), ]; } private function totalFromBalances(array $balances): float { return (float) $this->sumBalances($balances); } private function sumBalances(array $balances): string { $total = '0.0000000000'; foreach ($balances as $balance) { $amount = $this->decimalAmount($balance); if ($amount !== null) { $total = bcadd($total, $amount, 10); } } return $total; } private function decimalAmount($value): ?string { if (!is_int($value) && !is_float($value) && !is_string($value)) { return null; } $value = trim((string) $value); if (!preg_match('/^\d+(?:\.\d+)?$/D', $value)) { return null; } return bcadd($value, '0', 10); } private function request(string $path, array $data, bool $transfer = false) { if (!$this->configured()) { return null; } $random = $this->random(); try { return Http::asJson() ->withHeaders([ 'sign' => md5($random . config('third_game.sn') . config('third_game.key')), 'random' => $random, 'sn' => config('third_game.sn'), ]) ->timeout(max(1, (int) config( $transfer ? 'third_game.transfer_timeout' : 'third_game.timeout', $transfer ? 65 : 10 ))) ->connectTimeout(5) ->withOptions($this->httpOptions()) ->post(config('third_game.api_url') . $path, $data); } catch (\Throwable $e) { Log::warning('third_game_request_failed', [ 'path' => $path, 'message' => $e->getMessage(), ]); return null; } } private function responseMessage($response): string { if (!$response instanceof Response) { return $this->configured() ? '三方游戏接口请求失败' : '三方游戏配置缺失'; } return (string) ($response->json('msg') ?: '三方游戏接口异常'); } private function formatOrder(array $row): array { $gameType = (int) ($row['gameType'] ?? 0); $status = (int) ($row['status'] ?? -1); return [ 'player_id' => (string) ($row['playerId'] ?? ''), 'platform' => (string) ($row['platType'] ?? ''), 'currency' => (string) ($row['currency'] ?? ''), 'game_type' => $gameType, 'game_type_text' => self::GAME_TYPE_NAMES[$gameType] ?? '未知', 'game_name' => (string) ($row['gameName'] ?? ''), 'round' => (string) ($row['round'] ?? ''), 'table' => (string) ($row['table'] ?? ''), 'seat' => (string) ($row['seat'] ?? ''), 'bet_amount' => $this->numericValue($row['betAmount'] ?? null), 'valid_amount' => $this->numericValue($row['validAmount'] ?? null), 'settled_amount' => $this->numericValue($row['settledAmount'] ?? null), 'bet_content' => $row['betContent'] ?? '', 'status' => $status, 'status_text' => self::ORDER_STATUS_NAMES[$status] ?? '未知', 'game_order_id' => (string) ($row['gameOrderId'] ?? ''), 'bet_time' => (string) ($row['betTime'] ?? ''), 'last_update_time' => (string) ($row['lastUpdateTime'] ?? ''), ]; } private function numericValue($value) { return is_numeric($value) ? $value + 0 : null; } private function historyRangeError(string $startTime, string $endTime): ?string { $timezone = config('app.timezone', 'Asia/Shanghai'); $start = Carbon::createFromFormat('Y-m-d H:i:s', $startTime, $timezone); $end = Carbon::createFromFormat('Y-m-d H:i:s', $endTime, $timezone); if ($start->gt($end)) { return '开始时间不能大于结束时间'; } if ($start->diffInSeconds($end) > 6 * 60 * 60) { return '查询时间范围不能超过6小时'; } if ($start->lt(Carbon::now($timezone)->subDays(15))) { return '只能查询最近15天内的订单'; } return null; } private function acquireHistoryQuota( string $startTime, string $endTime, int $page, int $limit ): ?string { $prefix = $this->historyRatePrefix(); $sessionKey = $this->historySessionKey($startTime, $endTime, $limit); $lock = Cache::lock($prefix . ':lock', 5); if (!$lock->get()) { return '订单查询正在处理中,请稍后重试'; } try { $now = time(); $lastRequestAt = (int) Cache::get($prefix . ':last_request', 0); if ($lastRequestAt > 0 && $now - $lastRequestAt < self::HISTORY_PAGE_INTERVAL_SECONDS) { $retryAfter = self::HISTORY_PAGE_INTERVAL_SECONDS - ($now - $lastRequestAt); return "订单请求至少间隔10秒,请在{$retryAfter}秒后重试"; } $isPagination = $page > 1 && Cache::has($sessionKey); if (!$isPagination) { $lastQueryAt = (int) Cache::get($prefix . ':last_query', 0); if ($lastQueryAt > 0 && $now - $lastQueryAt < self::HISTORY_QUERY_INTERVAL_SECONDS) { $retryAfter = self::HISTORY_QUERY_INTERVAL_SECONDS - ($now - $lastQueryAt); return "订单查询至少间隔1分钟,请在{$retryAfter}秒后重试"; } $hourlyKey = $prefix . ':hourly'; if (RateLimiter::tooManyAttempts($hourlyKey, self::HISTORY_HOURLY_LIMIT)) { $retryAfter = max(1, RateLimiter::availableIn($hourlyKey)); return "订单查询每小时最多5次,请在{$retryAfter}秒后重试"; } RateLimiter::hit($hourlyKey, 3600); Cache::put($prefix . ':last_query', $now, self::HISTORY_QUERY_INTERVAL_SECONDS); } Cache::put($prefix . ':last_request', $now, self::HISTORY_PAGE_INTERVAL_SECONDS); return null; } finally { $lock->release(); } } private function acquireRealtimeQuota(int $page, int $limit): ?string { $sessionKey = $this->realtimeSessionKey($limit); if ($page > 1 && Cache::has($sessionKey)) { return null; } $prefix = $this->realtimeRatePrefix(); $lock = Cache::lock($prefix . ':lock', 5); if (!$lock->get()) { return '实时订单同步正在处理中,请稍后重试'; } try { if ($page > 1 && Cache::has($sessionKey)) { return null; } $now = time(); $lastQueryAt = (int) Cache::get($prefix . ':last_query', 0); if ($lastQueryAt > 0 && $now - $lastQueryAt < self::REALTIME_QUERY_INTERVAL_SECONDS) { $retryAfter = self::REALTIME_QUERY_INTERVAL_SECONDS - ($now - $lastQueryAt); return "实时订单同步每分钟最多请求1次,请在{$retryAfter}秒后重试"; } Cache::put($prefix . ':last_query', $now, self::REALTIME_QUERY_INTERVAL_SECONDS); return null; } finally { $lock->release(); } } private function configured(): bool { return config('third_game.api_url') !== '' && config('third_game.sn') !== '' && config('third_game.key') !== ''; } private function isPlayerNotFound(string $message): bool { $message = strtolower($message); return str_contains($message, 'playerid') && (str_contains($message, '不存在') || str_contains($message, 'not exist') || str_contains($message, 'not found')); } private function httpOptions(): array { $options = []; $proxy = (string) config('third_game.proxy', ''); if ($proxy !== '') { $options['proxy'] = $proxy; } $caBundle = (string) config('third_game.ca_bundle', ''); if ($caBundle !== '') { $options['verify'] = $caBundle; } return $options; } private function cacheKey(string $memberId): string { return 'third_game_total_balance:' . $memberId; } private function detailCacheKey(string $memberId): string { return 'third_game_balance_detail:' . $memberId; } private function historyRatePrefix(): string { return 'third_game_order_history:' . md5((string) config('third_game.sn')); } private function realtimeRatePrefix(): string { return 'third_game_order_realtime:' . md5((string) config('third_game.sn')); } private function historySessionKey(string $startTime, string $endTime, int $limit): string { return $this->historyRatePrefix() . ':session:' . md5($startTime . '|' . $endTime . '|' . $limit); } private function rememberHistorySession(string $startTime, string $endTime, int $limit): void { Cache::put($this->historySessionKey($startTime, $endTime, $limit), true, 3600); } private function realtimeSessionKey(int $limit): string { return $this->realtimeRatePrefix() . ':session:' . $limit; } private function rememberRealtimeSession(int $limit): void { Cache::put($this->realtimeSessionKey($limit), true, 600); } private function clearCache(string $memberId): void { Cache::forget($this->cacheKey($memberId)); Cache::forget($this->detailCacheKey($memberId)); } private function random(): string { $characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; $value = ''; for ($i = 0; $i < 32; $i++) { $value .= $characters[random_int(0, strlen($characters) - 1)]; } return $value; } }