ThirdGameBalanceService.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Http\Client\Pool;
  4. use Illuminate\Http\Client\Response;
  5. use Illuminate\Support\Facades\Cache;
  6. use Illuminate\Support\Facades\Http;
  7. use Illuminate\Support\Facades\Log;
  8. class ThirdGameBalanceService
  9. {
  10. private const CODE_SUCCESS = 10000;
  11. /**
  12. * 批量查询用户的三方游戏总余额,三方与主钱包金额按 1:1 处理。
  13. *
  14. * @param array<int, string|int> $memberIds
  15. * @return array<string, float|null>
  16. */
  17. public function totals(array $memberIds): array
  18. {
  19. $memberIds = array_values(array_unique(array_map('strval', $memberIds)));
  20. $totals = [];
  21. $pending = [];
  22. foreach ($memberIds as $memberId) {
  23. $cached = Cache::get($this->cacheKey($memberId));
  24. if ($cached !== null) {
  25. $totals[$memberId] = (float) $cached;
  26. } else {
  27. $pending[] = $memberId;
  28. }
  29. }
  30. if ($pending === []) {
  31. return $totals;
  32. }
  33. if (!$this->configured()) {
  34. Log::warning('third_game_balance_config_missing');
  35. foreach ($pending as $memberId) {
  36. $totals[$memberId] = null;
  37. }
  38. return $totals;
  39. }
  40. try {
  41. $responses = Http::pool(function (Pool $pool) use ($pending) {
  42. $requests = [];
  43. foreach ($pending as $memberId) {
  44. $random = $this->random();
  45. $requests[] = $pool->as($memberId)
  46. ->asJson()
  47. ->withHeaders([
  48. 'sign' => md5($random . config('third_game.sn') . config('third_game.key')),
  49. 'random' => $random,
  50. 'sn' => config('third_game.sn'),
  51. ])
  52. ->timeout(max(1, (int) config('third_game.timeout', 10)))
  53. ->connectTimeout(5)
  54. ->withOptions($this->httpOptions())
  55. ->post(config('third_game.api_url') . '/api/server/balanceAll', [
  56. 'playerId' => $this->playerId($memberId),
  57. 'currency' => config('third_game.currency', 'CNY'),
  58. ]);
  59. }
  60. return $requests;
  61. });
  62. } catch (\Throwable $e) {
  63. Log::warning('third_game_balance_pool_failed', ['message' => $e->getMessage()]);
  64. foreach ($pending as $memberId) {
  65. $totals[$memberId] = null;
  66. }
  67. return $totals;
  68. }
  69. foreach ($pending as $memberId) {
  70. $response = $responses[$memberId] ?? null;
  71. $balances = $this->balancesFromResponse($response);
  72. $total = $balances === null ? null : $this->totalFromBalances($balances);
  73. $totals[$memberId] = $total;
  74. if ($total !== null) {
  75. Cache::put(
  76. $this->cacheKey($memberId),
  77. $total,
  78. max(1, (int) config('third_game.cache_seconds', 30))
  79. );
  80. Cache::put(
  81. $this->detailCacheKey($memberId),
  82. $balances,
  83. max(1, (int) config('third_game.cache_seconds', 30))
  84. );
  85. }
  86. }
  87. return $totals;
  88. }
  89. public function playerId(string $memberId): string
  90. {
  91. return 'p' . substr(md5(config('third_game.sn') . '_' . $memberId), 0, 10);
  92. }
  93. /**
  94. * 查询单个用户在 ag、pg 等各游戏平台的余额。
  95. */
  96. public function detail(string $memberId, bool $forceRefresh = false): array
  97. {
  98. if (!$forceRefresh) {
  99. $cached = Cache::get($this->detailCacheKey($memberId));
  100. if (is_array($cached)) {
  101. return $this->detailResult($memberId, $cached);
  102. }
  103. }
  104. $response = $this->request('/api/server/balanceAll', [
  105. 'playerId' => $this->playerId($memberId),
  106. 'currency' => config('third_game.currency', 'CNY'),
  107. ]);
  108. $balances = $this->balancesFromResponse($response);
  109. if ($balances === null) {
  110. return ['ok' => false, 'msg' => $this->responseMessage($response)];
  111. }
  112. $ttl = max(1, (int) config('third_game.cache_seconds', 30));
  113. Cache::put($this->detailCacheKey($memberId), $balances, $ttl);
  114. Cache::put($this->cacheKey($memberId), $this->totalFromBalances($balances), $ttl);
  115. return $this->detailResult($memberId, $balances);
  116. }
  117. /**
  118. * 一键把用户在所有三方游戏平台的余额转出。
  119. */
  120. public function recycle(string $memberId): array
  121. {
  122. // 必须在调用 transferAll 前校验,避免三方已转出后才因本地配置错误中断。
  123. $this->recycleRate();
  124. $response = $this->request('/api/server/transferAll', [
  125. 'playerId' => $this->playerId($memberId),
  126. 'currency' => config('third_game.currency', 'CNY'),
  127. ], true);
  128. $body = $response instanceof Response ? $response->json() : null;
  129. if (!is_array($body)) {
  130. return [
  131. 'ok' => false,
  132. 'uncertain' => true,
  133. 'msg' => $response instanceof Response
  134. ? '三方游戏接口返回异常,回收结果待核对'
  135. : '三方游戏接口请求失败,回收结果待核对',
  136. ];
  137. }
  138. if ((int) ($body['code'] ?? 0) === self::CODE_SUCCESS) {
  139. $data = $body['data'] ?? null;
  140. if (!is_array($data)
  141. || !array_key_exists('balanceAll', $data)
  142. || !is_numeric($data['balanceAll'])
  143. || (float) $data['balanceAll'] < 0) {
  144. return [
  145. 'ok' => false,
  146. 'uncertain' => true,
  147. 'msg' => '三方游戏回收成功响应缺少有效 balanceAll,回收结果待核对',
  148. ];
  149. }
  150. $gameBalance = $this->decimalAmount($data['balanceAll']);
  151. if ($gameBalance === null) {
  152. return [
  153. 'ok' => false,
  154. 'uncertain' => true,
  155. 'msg' => '三方游戏回收金额格式异常,回收结果待核对',
  156. ];
  157. }
  158. $this->clearCache($memberId);
  159. return [
  160. 'ok' => true,
  161. 'game_balance' => $gameBalance,
  162. 'wallet_balance' => $this->toWalletBalance($gameBalance),
  163. ];
  164. }
  165. if ($this->isPlayerNotFound((string) ($body['msg'] ?? ''))) {
  166. $this->clearCache($memberId);
  167. return ['ok' => true, 'game_balance' => '0.0000000000', 'wallet_balance' => '0.0000000000'];
  168. }
  169. return [
  170. 'ok' => false,
  171. 'uncertain' => false,
  172. 'msg' => $this->responseMessage($response),
  173. ];
  174. }
  175. public function toWalletBalance($gameBalance): string
  176. {
  177. $balance = $this->decimalAmount($gameBalance);
  178. if ($balance === null) {
  179. throw new \InvalidArgumentException('三方游戏余额格式异常');
  180. }
  181. return bcdiv($balance, $this->recycleRate(), 10);
  182. }
  183. private function recycleRate(): string
  184. {
  185. $rate = trim((string) config('third_game.recycle_rate', '1'));
  186. if (!preg_match('/^\d+(?:\.\d+)?$/D', $rate) || bccomp($rate, '0', 10) <= 0) {
  187. throw new \RuntimeException('GAME_RECYCLE_RATE 必须是大于 0 的数字');
  188. }
  189. return $rate;
  190. }
  191. private function balancesFromResponse($response): ?array
  192. {
  193. if (!$response instanceof Response) {
  194. return null;
  195. }
  196. $body = $response->json();
  197. if (!is_array($body)) {
  198. return null;
  199. }
  200. if ((int) ($body['code'] ?? 0) === self::CODE_SUCCESS) {
  201. return is_array($body['data'] ?? null) ? $body['data'] : [];
  202. }
  203. if ($this->isPlayerNotFound((string) ($body['msg'] ?? ''))) {
  204. return [];
  205. }
  206. Log::warning('third_game_balance_query_failed', [
  207. 'code' => $body['code'] ?? null,
  208. 'msg' => $body['msg'] ?? null,
  209. ]);
  210. return null;
  211. }
  212. private function detailResult(string $memberId, array $balances): array
  213. {
  214. $gameBalance = $this->sumBalances($balances);
  215. $walletBalances = [];
  216. $gameBalances = [];
  217. foreach ($balances as $platform => $balance) {
  218. if (is_numeric($balance)) {
  219. $walletBalances[] = [
  220. 'platform' => (string) $platform,
  221. 'balance' => (float) $balance,
  222. ];
  223. $gameBalances[] = [
  224. 'platform' => (string) $platform,
  225. 'balance' => (float) $balance,
  226. ];
  227. }
  228. }
  229. return [
  230. 'ok' => true,
  231. 'member_id' => $memberId,
  232. 'player_id' => $this->playerId($memberId),
  233. 'currency' => (string) config('third_game.currency', 'CNY'),
  234. 'rate' => 1,
  235. 'list' => $walletBalances,
  236. 'game_list' => $gameBalances,
  237. 'total_game_balance' => (float) $gameBalance,
  238. 'total_balance' => $this->totalFromBalances($balances),
  239. ];
  240. }
  241. private function totalFromBalances(array $balances): float
  242. {
  243. return (float) $this->sumBalances($balances);
  244. }
  245. private function sumBalances(array $balances): string
  246. {
  247. $total = '0.0000000000';
  248. foreach ($balances as $balance) {
  249. $amount = $this->decimalAmount($balance);
  250. if ($amount !== null) {
  251. $total = bcadd($total, $amount, 10);
  252. }
  253. }
  254. return $total;
  255. }
  256. private function decimalAmount($value): ?string
  257. {
  258. if (!is_int($value) && !is_float($value) && !is_string($value)) {
  259. return null;
  260. }
  261. $value = trim((string) $value);
  262. if (!preg_match('/^\d+(?:\.\d+)?$/D', $value)) {
  263. return null;
  264. }
  265. return bcadd($value, '0', 10);
  266. }
  267. private function request(string $path, array $data, bool $transfer = false)
  268. {
  269. if (!$this->configured()) {
  270. return null;
  271. }
  272. $random = $this->random();
  273. try {
  274. return Http::asJson()
  275. ->withHeaders([
  276. 'sign' => md5($random . config('third_game.sn') . config('third_game.key')),
  277. 'random' => $random,
  278. 'sn' => config('third_game.sn'),
  279. ])
  280. ->timeout(max(1, (int) config(
  281. $transfer ? 'third_game.transfer_timeout' : 'third_game.timeout',
  282. $transfer ? 65 : 10
  283. )))
  284. ->connectTimeout(5)
  285. ->withOptions($this->httpOptions())
  286. ->post(config('third_game.api_url') . $path, $data);
  287. } catch (\Throwable $e) {
  288. Log::warning('third_game_request_failed', [
  289. 'path' => $path,
  290. 'message' => $e->getMessage(),
  291. ]);
  292. return null;
  293. }
  294. }
  295. private function responseMessage($response): string
  296. {
  297. if (!$response instanceof Response) {
  298. return $this->configured() ? '三方游戏接口请求失败' : '三方游戏配置缺失';
  299. }
  300. return (string) ($response->json('msg') ?: '三方游戏接口异常');
  301. }
  302. private function configured(): bool
  303. {
  304. return config('third_game.api_url') !== ''
  305. && config('third_game.sn') !== ''
  306. && config('third_game.key') !== '';
  307. }
  308. private function isPlayerNotFound(string $message): bool
  309. {
  310. $message = strtolower($message);
  311. return str_contains($message, 'playerid')
  312. && (str_contains($message, '不存在')
  313. || str_contains($message, 'not exist')
  314. || str_contains($message, 'not found'));
  315. }
  316. private function httpOptions(): array
  317. {
  318. $options = [];
  319. $proxy = (string) config('third_game.proxy', '');
  320. if ($proxy !== '') {
  321. $options['proxy'] = $proxy;
  322. }
  323. $caBundle = (string) config('third_game.ca_bundle', '');
  324. if ($caBundle !== '') {
  325. $options['verify'] = $caBundle;
  326. }
  327. return $options;
  328. }
  329. private function cacheKey(string $memberId): string
  330. {
  331. return 'third_game_total_balance:' . $memberId;
  332. }
  333. private function detailCacheKey(string $memberId): string
  334. {
  335. return 'third_game_balance_detail:' . $memberId;
  336. }
  337. private function clearCache(string $memberId): void
  338. {
  339. Cache::forget($this->cacheKey($memberId));
  340. Cache::forget($this->detailCacheKey($memberId));
  341. }
  342. private function random(): string
  343. {
  344. $characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
  345. $value = '';
  346. for ($i = 0; $i < 32; $i++) {
  347. $value .= $characters[random_int(0, strlen($characters) - 1)];
  348. }
  349. return $value;
  350. }
  351. }