ThirdGameOrderService.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. <?php
  2. namespace App\Services;
  3. use App\Models\ThirdGameOrder as ThirdGameOrderModel;
  4. use App\Services\AdminRealtime\AdminRealtime;
  5. use Carbon\Carbon;
  6. use Illuminate\Support\Facades\DB;
  7. class ThirdGameOrderService
  8. {
  9. private const GAME_TYPE_NAMES = [
  10. 1 => '视讯',
  11. 2 => '老虎机',
  12. 3 => '彩票',
  13. 4 => '体育',
  14. 5 => '电竞',
  15. 6 => '捕猎',
  16. 7 => '棋牌',
  17. ];
  18. private const STATUS_NAMES = [
  19. 0 => '未完成',
  20. 1 => '已完成',
  21. 2 => '已取消',
  22. 3 => '已撤单',
  23. ];
  24. private ThirdGameBalanceService $provider;
  25. public function __construct(ThirdGameBalanceService $provider)
  26. {
  27. $this->provider = $provider;
  28. }
  29. public function syncHistory(
  30. string $startTime,
  31. string $endTime,
  32. int $page = 1,
  33. int $limit = 2000
  34. ): array {
  35. return $this->persistProviderResult(
  36. $this->provider->historyOrders($startTime, $endTime, $page, $limit)
  37. );
  38. }
  39. public function syncRealtime(int $page = 1, int $limit = 2000): array
  40. {
  41. return $this->persistProviderResult($this->provider->realtimeOrders($page, $limit));
  42. }
  43. private function persistProviderResult(array $result): array
  44. {
  45. if (empty($result['ok'])) {
  46. return $result;
  47. }
  48. $orders = is_array($result['list'] ?? null) ? $result['list'] : [];
  49. $received = count($orders);
  50. $testNamespace = $this->provider->playerPrefix() === 'tu';
  51. $orders = array_values(array_filter($orders, static function (array $order) use ($testNamespace): bool {
  52. $isTestPlayer = str_starts_with(trim((string) ($order['player_id'] ?? '')), 'tu');
  53. return $testNamespace === $isTestPlayer;
  54. }));
  55. $users = $this->usersByPlayerId(array_values(array_unique(array_filter(array_map(
  56. static fn(array $order): string => trim((string) ($order['player_id'] ?? '')),
  57. $orders
  58. )))));
  59. $existing = $this->existingSnapshots($orders);
  60. $now = Carbon::now(config('app.timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s');
  61. $rows = [];
  62. $matchedUserIds = [];
  63. foreach ($orders as $order) {
  64. $platform = trim((string) ($order['platform'] ?? ''));
  65. $gameOrderId = trim((string) ($order['game_order_id'] ?? ''));
  66. if ($gameOrderId === '') {
  67. continue;
  68. }
  69. $playerId = trim((string) ($order['player_id'] ?? ''));
  70. $identity = $this->identity($platform, $playerId, $gameOrderId);
  71. $user = $users[$playerId] ?? null;
  72. $previous = $existing[$identity] ?? null;
  73. $userId = $user->id ?? $previous->user_id ?? null;
  74. if ($userId !== null) {
  75. $matchedUserIds[(int) $userId] = true;
  76. }
  77. $rows[$identity] = [
  78. 'order_key' => $identity,
  79. 'user_id' => $userId,
  80. 'member_id' => $user->member_id ?? $previous->member_id ?? null,
  81. 'username' => (string) ($user->username ?? $previous->username ?? ''),
  82. 'first_name' => (string) ($user->first_name ?? $previous->first_name ?? ''),
  83. 'player_id' => $playerId,
  84. 'platform' => $platform,
  85. 'currency' => trim((string) ($order['currency'] ?? '')),
  86. 'game_type' => min(255, max(0, (int) ($order['game_type'] ?? 0))),
  87. 'game_name' => (string) ($order['game_name'] ?? ''),
  88. 'round_no' => (string) ($order['round'] ?? ''),
  89. 'table_no' => (string) ($order['table'] ?? ''),
  90. 'seat' => (string) ($order['seat'] ?? ''),
  91. 'bet_amount' => $this->decimal($order['bet_amount'] ?? null),
  92. 'valid_amount' => $this->decimal($order['valid_amount'] ?? null),
  93. 'settled_amount' => $this->decimal($order['settled_amount'] ?? null),
  94. 'bet_content' => $this->text($order['bet_content'] ?? ''),
  95. 'status' => min(255, max(0, (int) ($order['status'] ?? 0))),
  96. 'game_order_id' => $gameOrderId,
  97. 'bet_time' => $this->dateTime($order['bet_time'] ?? null),
  98. 'last_update_time' => $this->dateTime($order['last_update_time'] ?? null),
  99. 'created_at' => $previous->created_at ?? $now,
  100. 'updated_at' => $now,
  101. ];
  102. }
  103. if ($rows !== []) {
  104. $changedKeys = array_keys(array_filter($rows, fn(array $row, string $key): bool
  105. => $this->snapshotChanged($existing[$key] ?? null, $row), ARRAY_FILTER_USE_BOTH));
  106. ThirdGameOrderModel::query()->upsert(
  107. array_values($rows),
  108. ['order_key'],
  109. [
  110. 'user_id', 'member_id', 'username', 'first_name', 'player_id', 'currency',
  111. 'game_type', 'game_name', 'round_no', 'table_no', 'seat', 'bet_amount',
  112. 'valid_amount', 'settled_amount', 'bet_content', 'status', 'bet_time',
  113. 'last_update_time', 'updated_at',
  114. ]
  115. );
  116. $this->recordRealtime($changedKeys, $existing);
  117. }
  118. $pageNo = (int) ($result['page_no'] ?? $page);
  119. $pageSize = (int) ($result['page_size'] ?? $limit);
  120. $total = (int) ($result['total'] ?? $received);
  121. return [
  122. 'ok' => true,
  123. 'received' => $received,
  124. 'synced' => count($rows),
  125. 'matched_users' => count($matchedUserIds),
  126. 'unmatched_orders' => count(array_filter($rows, static fn(array $row): bool => $row['user_id'] === null)),
  127. 'total' => $total,
  128. 'page_no' => $pageNo,
  129. 'page_size' => $pageSize,
  130. 'has_more' => $pageSize > 0 && $pageNo * $pageSize < $total,
  131. ];
  132. }
  133. public function syncForList(array $params): array
  134. {
  135. if ((int) ($params['page'] ?? 1) > 1) {
  136. return ['attempted' => false, 'ok' => true, 'message' => ''];
  137. }
  138. $result = $this->syncRealtime(1, 2000);
  139. if (empty($result['ok'])) {
  140. return [
  141. 'attempted' => true,
  142. 'ok' => false,
  143. 'message' => (string) ($result['msg'] ?? '三方订单同步失败'),
  144. ];
  145. }
  146. unset($result['ok']);
  147. return array_merge([
  148. 'attempted' => true,
  149. 'ok' => true,
  150. 'message' => '',
  151. ], $result);
  152. }
  153. public function paginate(array $params): array
  154. {
  155. $page = max(1, (int) ($params['page'] ?? 1));
  156. $limit = min(200, max(1, (int) ($params['limit'] ?? 20)));
  157. $query = ThirdGameOrderModel::query()
  158. ->leftJoin('users', 'users.id', '=', 'third_game_orders.user_id');
  159. if ($this->provider->playerPrefix() === 'tu') $query->where('third_game_orders.player_id', 'like', 'tu%');
  160. else $query->where('third_game_orders.player_id', 'not like', 'tu%');
  161. if (!empty($params['user_id'])) {
  162. $userId = (int) $params['user_id'];
  163. $query->where(function ($query) use ($userId) {
  164. $query->where('users.id', $userId)
  165. ->orWhere(function ($query) use ($userId) {
  166. $query->whereNull('users.id')
  167. ->where('third_game_orders.user_id', $userId);
  168. });
  169. });
  170. }
  171. if (!empty($params['member_id'])) {
  172. $memberId = (string) $params['member_id'];
  173. $query->where(function ($query) use ($memberId) {
  174. $query->where('users.member_id', $memberId)
  175. ->orWhere(function ($query) use ($memberId) {
  176. $query->whereNull('users.id')
  177. ->where('third_game_orders.member_id', $memberId);
  178. });
  179. });
  180. }
  181. if (!empty($params['username'])) {
  182. $username = '%' . $params['username'] . '%';
  183. $query->where(function ($query) use ($username) {
  184. $query->where('users.username', 'like', $username)
  185. ->orWhere(function ($query) use ($username) {
  186. $query->whereNull('users.id')
  187. ->where('third_game_orders.username', 'like', $username);
  188. });
  189. });
  190. }
  191. if (!empty($params['account'])) {
  192. $query->where('users.account', 'like', '%' . trim((string) $params['account']) . '%');
  193. }
  194. if (!empty($params['first_name'])) {
  195. $firstName = '%' . $params['first_name'] . '%';
  196. $query->where(function ($query) use ($firstName) {
  197. $query->where('users.first_name', 'like', $firstName)
  198. ->orWhere(function ($query) use ($firstName) {
  199. $query->whereNull('users.id')
  200. ->where('third_game_orders.first_name', 'like', $firstName);
  201. });
  202. });
  203. }
  204. // 内部参数:后台实时推送按主键取单行
  205. if (!empty($params['id'])) {
  206. $query->where('third_game_orders.id', (int) $params['id']);
  207. }
  208. if (!empty($params['player_id'])) {
  209. $query->where('third_game_orders.player_id', (string) $params['player_id']);
  210. }
  211. if (!empty($params['platform'])) {
  212. $query->where('third_game_orders.platform', (string) $params['platform']);
  213. }
  214. if (!empty($params['game_order_id'])) {
  215. $query->where('third_game_orders.game_order_id', (string) $params['game_order_id']);
  216. }
  217. if (isset($params['game_type']) && $params['game_type'] !== '') {
  218. $query->where('third_game_orders.game_type', (int) $params['game_type']);
  219. }
  220. if (isset($params['status']) && $params['status'] !== '') {
  221. $query->where('third_game_orders.status', (int) $params['status']);
  222. }
  223. if (!empty($params['start_time']) && !empty($params['end_time'])) {
  224. $query->whereBetween('third_game_orders.last_update_time', [
  225. $params['start_time'],
  226. $params['end_time'],
  227. ]);
  228. }
  229. $total = (clone $query)->count('third_game_orders.id');
  230. $list = $query
  231. ->select('third_game_orders.*')
  232. ->addSelect([
  233. 'users.id as current_user_id',
  234. 'users.member_id as current_member_id',
  235. 'users.username as current_username',
  236. 'users.account as current_account',
  237. 'users.first_name as current_first_name',
  238. ])
  239. ->orderByDesc('third_game_orders.last_update_time')
  240. ->orderByDesc('third_game_orders.id')
  241. ->forPage($page, $limit)
  242. ->get()
  243. ->map(fn(ThirdGameOrderModel $order): array => $this->format($order))
  244. ->all();
  245. return [
  246. 'total' => $total,
  247. 'page' => $page,
  248. 'limit' => $limit,
  249. 'list' => $list,
  250. 'options' => [
  251. 'game_types' => $this->options(self::GAME_TYPE_NAMES),
  252. 'statuses' => $this->options(self::STATUS_NAMES),
  253. ],
  254. ];
  255. }
  256. /**
  257. * @return array<string, object>
  258. */
  259. private function usersByPlayerId(array $playerIds): array
  260. {
  261. $requested = []; $prefix = $this->provider->playerPrefix();
  262. $pattern = '/^' . preg_quote($prefix, '/') . '(\d{1,' . (11 - strlen($prefix)) . '})$/D';
  263. foreach ($playerIds as $playerId) {
  264. $playerId = trim((string) $playerId);
  265. if (!preg_match($pattern, $playerId, $matches)) {
  266. continue;
  267. }
  268. $userId = filter_var(ltrim($matches[1], '0'), FILTER_VALIDATE_INT, [
  269. 'options' => ['min_range' => 1, 'max_range' => PHP_INT_MAX],
  270. ]);
  271. if ($userId !== false && ($playerId === $prefix . $userId || $playerId === $this->provider->playerIdFromUserId($userId))) {
  272. $requested[(int) $userId][$playerId] = true;
  273. }
  274. }
  275. $result = $this->usersByLegacyPlayerId($playerIds);
  276. if ($requested === []) {
  277. return $result;
  278. }
  279. $rows = DB::table('users')
  280. ->select(['id', 'member_id', 'username', 'first_name'])
  281. ->whereIn('id', array_keys($requested))
  282. ->get();
  283. foreach ($rows as $row) {
  284. foreach (array_keys($requested[(int) $row->id] ?? []) as $playerId) {
  285. $result[$playerId] = $row;
  286. }
  287. }
  288. return $result;
  289. }
  290. /**
  291. * 2026-08-24 之前的三方账号为 p + md5(sn_member_id) 前10位,历史订单仍使用该账号。
  292. *
  293. * @return array<string, object>
  294. */
  295. private function usersByLegacyPlayerId(array $playerIds): array
  296. {
  297. $legacy = [];
  298. foreach ($playerIds as $playerId) {
  299. $playerId = trim((string) $playerId);
  300. if (preg_match('/^p[0-9a-f]{10}$/D', $playerId)) $legacy[$playerId] = true;
  301. }
  302. if ($legacy === []) {
  303. return [];
  304. }
  305. $sn = (string) config('third_game.sn');
  306. $expression = "CONCAT('p', SUBSTRING(MD5(CONCAT(?, '_', `member_id`)), 1, 10))";
  307. $placeholders = implode(',', array_fill(0, count($legacy), '?'));
  308. $rows = DB::table('users')
  309. ->select(['id', 'member_id', 'username', 'first_name'])
  310. ->selectRaw($expression . ' AS legacy_player_id', [$sn])
  311. ->whereRaw($expression . " IN ({$placeholders})", array_merge([$sn], array_keys($legacy)))
  312. ->get();
  313. $result = [];
  314. foreach ($rows as $row) {
  315. $result[(string) $row->legacy_player_id] = $row;
  316. }
  317. return $result;
  318. }
  319. /** 补齐同步时未能识别旧账号而缺少会员信息的订单。 */
  320. public function backfillLegacyPlayers(): int
  321. {
  322. $updated = 0;
  323. $playerIds = ThirdGameOrderModel::query()->whereNull('user_id')->where('player_id', 'like', 'p%')
  324. ->distinct()->pluck('player_id')->all();
  325. foreach (array_chunk($playerIds, 200) as $chunk) {
  326. foreach ($this->usersByLegacyPlayerId($chunk) as $playerId => $user) {
  327. $ids = ThirdGameOrderModel::query()->whereNull('user_id')->where('player_id', $playerId)->pluck('id')->all();
  328. $updated += ThirdGameOrderModel::query()->whereNull('user_id')->where('player_id', $playerId)->update([
  329. 'user_id' => $user->id,
  330. 'member_id' => $user->member_id,
  331. 'username' => (string) ($user->username ?? ''),
  332. 'first_name' => (string) ($user->first_name ?? ''),
  333. ]);
  334. AdminRealtime::record('third_game_orders', $ids);
  335. }
  336. }
  337. return $updated;
  338. }
  339. /**
  340. * @return array<string, ThirdGameOrderModel>
  341. */
  342. /** 同步每分钟全量 upsert,只有新订单或状态、结算、会员归属变化才推送后台 */
  343. private function snapshotChanged($previous, array $row): bool
  344. {
  345. if ($previous === null) {
  346. return true;
  347. }
  348. $settled = $previous->getRawOriginal('settled_amount');
  349. $settledChanged = is_numeric($settled) && is_numeric($row['settled_amount'])
  350. ? bccomp((string) $settled, (string) $row['settled_amount'], 10) !== 0
  351. : $settled !== $row['settled_amount'];
  352. return $settledChanged
  353. || (int) $previous->getRawOriginal('status') !== (int) $row['status']
  354. || (string) $previous->getRawOriginal('last_update_time') !== (string) $row['last_update_time']
  355. || (string) $previous->getRawOriginal('user_id') !== (string) $row['user_id'];
  356. }
  357. private function recordRealtime(array $changedKeys, array $existing): void
  358. {
  359. if ($changedKeys === [] || !config('admin_realtime.enabled')) {
  360. return;
  361. }
  362. try {
  363. $ids = ThirdGameOrderModel::query()->whereIn('order_key', $changedKeys)->pluck('id', 'order_key')->all();
  364. $created = array_values(array_intersect_key($ids, array_diff_key(array_flip($changedKeys), $existing)));
  365. AdminRealtime::record('third_game_orders', $created, 'created');
  366. AdminRealtime::record('third_game_orders', array_values(array_diff($ids, $created)));
  367. } catch (\Throwable $e) {
  368. \Illuminate\Support\Facades\Log::channel('realtime')->error('admin_realtime_third_game_failed', ['error' => $e->getMessage()]);
  369. }
  370. }
  371. private function existingSnapshots(array $orders): array
  372. {
  373. $orderKeys = [];
  374. foreach ($orders as $order) {
  375. $gameOrderId = trim((string) ($order['game_order_id'] ?? ''));
  376. if ($gameOrderId === '') {
  377. continue;
  378. }
  379. $orderKeys[] = $this->identity(
  380. trim((string) ($order['platform'] ?? '')),
  381. trim((string) ($order['player_id'] ?? '')),
  382. $gameOrderId
  383. );
  384. }
  385. $orderKeys = array_values(array_unique($orderKeys));
  386. if ($orderKeys === []) {
  387. return [];
  388. }
  389. $rows = ThirdGameOrderModel::query()
  390. ->whereIn('order_key', $orderKeys)
  391. ->get(['id', 'order_key', 'user_id', 'member_id', 'username', 'first_name', 'status', 'settled_amount', 'last_update_time', 'created_at']);
  392. $result = [];
  393. foreach ($rows as $row) {
  394. $result[(string) $row->order_key] = $row;
  395. }
  396. return $result;
  397. }
  398. private function format(ThirdGameOrderModel $order): array
  399. {
  400. $userId = $order->current_user_id ?? $order->user_id;
  401. $memberId = $order->current_member_id ?? $order->member_id;
  402. $username = $order->current_username ?? $order->username;
  403. $firstName = $order->current_first_name ?? $order->first_name;
  404. $gameType = (int) $order->game_type;
  405. $status = (int) $order->status;
  406. return [
  407. 'id' => (int) $order->id,
  408. 'user_id' => $userId === null ? null : (int) $userId,
  409. 'member_id' => $memberId === null ? null : (string) $memberId,
  410. 'username' => (string) ($username ?? ''),
  411. 'account' => (string) ($order->current_account ?? ''),
  412. 'first_name' => (string) ($firstName ?? ''),
  413. 'player_id' => (string) $order->player_id,
  414. 'platform' => (string) $order->platform,
  415. 'currency' => (string) $order->currency,
  416. 'game_type' => $gameType,
  417. 'game_type_text' => self::GAME_TYPE_NAMES[$gameType] ?? '未知',
  418. 'game_name' => (string) $order->game_name,
  419. 'round' => (string) $order->round_no,
  420. 'table' => (string) $order->table_no,
  421. 'seat' => (string) $order->seat,
  422. 'bet_amount' => $this->number($order->bet_amount),
  423. 'valid_amount' => $this->number($order->valid_amount),
  424. 'settled_amount' => $this->number($order->settled_amount),
  425. 'bet_content' => (string) ($order->bet_content ?? ''),
  426. 'status' => $status,
  427. 'status_text' => self::STATUS_NAMES[$status] ?? '未知',
  428. 'game_order_id' => (string) $order->game_order_id,
  429. 'bet_time' => $this->formatTime($order->bet_time),
  430. 'last_update_time' => $this->formatTime($order->last_update_time),
  431. ];
  432. }
  433. private function identity(string $platform, string $playerId, string $gameOrderId): string
  434. {
  435. return hash('sha256', $platform . "\0" . $playerId . "\0" . $gameOrderId);
  436. }
  437. private function decimal($value): ?string
  438. {
  439. if (!is_numeric($value)) {
  440. return null;
  441. }
  442. $value = trim((string) $value);
  443. if (preg_match('/^-?\d+(?:\.\d+)?$/D', $value)) {
  444. return bcadd($value, '0', 10);
  445. }
  446. return number_format((float) $value, 10, '.', '');
  447. }
  448. private function text($value): string
  449. {
  450. if (is_array($value) || is_object($value)) {
  451. return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '';
  452. }
  453. return is_scalar($value) ? (string) $value : '';
  454. }
  455. private function dateTime($value): ?string
  456. {
  457. if ($value === null || $value === '') {
  458. return null;
  459. }
  460. try {
  461. return Carbon::parse($value, config('app.timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s');
  462. } catch (\Throwable $e) {
  463. return null;
  464. }
  465. }
  466. private function formatTime($value): ?string
  467. {
  468. return $value ? Carbon::parse($value)->format('Y-m-d H:i:s') : null;
  469. }
  470. private function number($value)
  471. {
  472. return $value === null || $value === '' ? null : (float) $value;
  473. }
  474. private function options(array $items): array
  475. {
  476. $options = [];
  477. foreach ($items as $value => $label) {
  478. $options[] = ['label' => $label, 'value' => $value];
  479. }
  480. return $options;
  481. }
  482. }