'视讯', 2 => '老虎机', 3 => '彩票', 4 => '体育', 5 => '电竞', 6 => '捕猎', 7 => '棋牌', ]; private const STATUS_NAMES = [ 0 => '未完成', 1 => '已完成', 2 => '已取消', 3 => '已撤单', ]; private ThirdGameBalanceService $provider; public function __construct(ThirdGameBalanceService $provider) { $this->provider = $provider; } public function syncHistory( string $startTime, string $endTime, int $page = 1, int $limit = 2000 ): array { return $this->persistProviderResult( $this->provider->historyOrders($startTime, $endTime, $page, $limit), $page, $limit ); } public function syncRealtime(int $page = 1, int $limit = 2000): array { return $this->persistProviderResult($this->provider->realtimeOrders($page, $limit), $page, $limit); } private function persistProviderResult(array $result, int $page = 1, int $limit = 2000): array { if (empty($result['ok'])) { return $result; } $orders = is_array($result['list'] ?? null) ? $result['list'] : []; $received = count($orders); $testNamespace = $this->provider->playerPrefix() === 'tu'; $orders = array_values(array_filter($orders, static function (array $order) use ($testNamespace): bool { $isTestPlayer = str_starts_with(trim((string) ($order['player_id'] ?? '')), 'tu'); return $testNamespace === $isTestPlayer; })); $users = $this->usersByPlayerId(array_values(array_unique(array_filter(array_map( static fn(array $order): string => trim((string) ($order['player_id'] ?? '')), $orders ))))); $now = Carbon::now(config('app.timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s'); $rows = []; foreach ($orders as $order) { $platform = trim((string) ($order['platform'] ?? '')); $gameOrderId = trim((string) ($order['game_order_id'] ?? '')); if ($gameOrderId === '') { continue; } $playerId = trim((string) ($order['player_id'] ?? '')); $identity = $this->identity($platform, $playerId, $gameOrderId); $updated = $this->dateTime($order['last_update_time'] ?? null); $status = min(255, max(0, (int) ($order['status'] ?? 0))); if (isset($rows[$identity]) && $this->isStale($updated, $rows[$identity]['last_update_time'], $rows[$identity]['status'])) { continue; } $rows[$identity] = [ 'order_key' => $identity, 'player_id' => $playerId, 'platform' => $platform, 'currency' => trim((string) ($order['currency'] ?? '')), 'game_type' => min(255, max(0, (int) ($order['game_type'] ?? 0))), 'game_name' => (string) ($order['game_name'] ?? ''), 'round_no' => (string) ($order['round'] ?? ''), 'table_no' => (string) ($order['table'] ?? ''), 'seat' => (string) ($order['seat'] ?? ''), 'bet_amount' => $this->decimal($order['bet_amount'] ?? null), 'valid_amount' => $this->decimal($order['valid_amount'] ?? null), 'settled_amount' => $this->decimal($order['settled_amount'] ?? null), 'bet_content' => $this->text($order['bet_content'] ?? ''), 'status' => $status, 'game_order_id' => $gameOrderId, 'bet_time' => $this->dateTime($order['bet_time'] ?? null), 'last_update_time' => $updated, 'created_at' => $now, 'updated_at' => $now, ]; } $synced = 0; $unmatched = 0; $matchedUserIds = []; if ($rows !== []) { $commission = app(ThirdGameCommissionService::class); $history = $commission->backfillState(); $rates = []; foreach ($rows as $row) { if (strtoupper($row['currency']) === $commission::currency() && $commission::result($row['status'], $row['settled_amount']) === 'loss') { $rates = $commission->rates()['ratios']; break; } } ksort($rows); foreach (array_chunk($rows, 200, true) as $batch) { $written = $this->writeOrders($batch, $users, $commission, $rates, $history); $synced += count($written); foreach ($written as $row) { if ($row['user_id'] === null) { ++$unmatched; } else { $matchedUserIds[(int) $row['user_id']] = true; } } } } $pageNo = (int) ($result['page_no'] ?? $page); $pageSize = (int) ($result['page_size'] ?? $limit); $total = (int) ($result['total'] ?? $received); return [ 'ok' => true, 'received' => $received, 'synced' => $synced, 'matched_users' => count($matchedUserIds), 'unmatched_orders' => $unmatched, 'total' => $total, 'page_no' => $pageNo, 'page_size' => $pageSize, 'has_more' => $pageSize > 0 && $pageNo * $pageSize < $total, ]; } private function writeOrders(array $batch, array $users, ThirdGameCommissionService $commission, array $rates, array $history): array { for ($attempt = 1; ; ++$attempt) { try { [$written, $existing] = DB::transaction(function () use ($batch, $users, $commission, $rates, $history) { $existing = ThirdGameOrderModel::query()->whereIn('order_key', array_keys($batch)) ->orderBy('order_key')->lockForUpdate()->get([ 'id', 'order_key', 'user_id', 'member_id', 'username', 'first_name', 'currency', 'status', 'settled_amount', 'last_update_time', 'third_game_commission_ratio', 'third_game_commission', ])->keyBy('order_key')->all(); $new = []; $updates = []; foreach ($batch as $key => $row) { $previous = $existing[$key] ?? null; if ($previous !== null && $this->isStale($row['last_update_time'], $previous->getRawOriginal('last_update_time'), (int) $previous->getRawOriginal('status'))) { continue; } $user = $users[$row['player_id']] ?? null; $row['user_id'] = $user->id ?? $previous->user_id ?? null; $row['member_id'] = $user->member_id ?? $previous->member_id ?? null; $row['username'] = (string) ($user->username ?? $previous->username ?? ''); $row['first_name'] = (string) ($user->first_name ?? $previous->first_name ?? ''); $row = $this->applyCommission($row, $previous, $commission, $rates, $history); if ($previous === null) { $new[$key] = $row; } else { $updates[$key] = $row; } } if ($new !== []) { // A concurrent insert must be re-read before applying a fee snapshot. ThirdGameOrderModel::query()->insert(array_values($new)); } if ($updates !== []) { ThirdGameOrderModel::query()->upsert(array_values($updates), ['order_key'], [ 'user_id', 'member_id', 'username', 'first_name', 'player_id', 'currency', 'game_type', 'game_name', 'round_no', 'table_no', 'seat', 'bet_amount', 'valid_amount', 'settled_amount', 'bet_content', 'status', 'bet_time', 'last_update_time', 'updated_at', 'third_game_commission_ratio', 'third_game_commission', ]); } return [$new + $updates, $existing]; }, 3); break; } catch (UniqueConstraintViolationException $e) { if ($attempt === 3) { throw $e; } } } $changedKeys = array_keys(array_filter($written, fn (array $row, string $key): bool => $this->snapshotChanged($existing[$key] ?? null, $row), ARRAY_FILTER_USE_BOTH)); $this->recordRealtime($changedKeys, $existing); return $written; } /** * Whether an incoming version is older than the stored one. Versions are ordered by the provider update time; * a version without one cannot be ordered, so it may only replace an unfinished version, never a finished * (settled/cancelled) one. A timed version always replaces an untimed one. */ private function isStale(?string $candidateTime, ?string $currentTime, int $currentStatus): bool { if ($candidateTime === null) return $currentStatus !== 0; return $currentTime !== null && $candidateTime < $currentTime; } private function applyCommission(array $row, ?ThirdGameOrderModel $previous, ThirdGameCommissionService $commission, array $rates, array $history): array { $currency = strtoupper($row['currency']); $ratio = $previous !== null && $currency === strtoupper(trim((string) $previous->currency)) ? $previous->third_game_commission_ratio : null; $result = $commission::result($row['status'], $row['settled_amount']); if ($result === 'loss') { if ($ratio === null && $currency === $commission::currency()) { if ($commission->isHistorical($previous?->id, $row['last_update_time'], $history)) { $rates = ($history['currency'] ?? null) === $currency ? $history['snapshot']['ratios'] : []; } $ratio = $rates[strtolower($row['platform'])] ?? null; } $amount = $ratio === null ? null : $commission::charge($row['settled_amount'], $ratio); } else { $amount = $row['status'] === 1 && $result === null ? null : Money::zero(); } return $row + ['third_game_commission_ratio' => $ratio, 'third_game_commission' => $amount]; } public function syncForList(array $params): array { if ((int) ($params['page'] ?? 1) > 1) { return ['attempted' => false, 'ok' => true, 'message' => '']; } $result = $this->syncRealtime(1, 2000); if (empty($result['ok'])) { return [ 'attempted' => true, 'ok' => false, 'message' => (string) ($result['msg'] ?? '三方订单同步失败'), ]; } unset($result['ok']); return array_merge([ 'attempted' => true, 'ok' => true, 'message' => '', ], $result); } public function paginate(array $params): array { $page = max(1, (int) ($params['page'] ?? 1)); $limit = min(200, max(1, (int) ($params['limit'] ?? 20))); $query = ThirdGameOrderModel::query() ->leftJoin('users', 'users.id', '=', 'third_game_orders.user_id'); if ($this->provider->playerPrefix() === 'tu') $query->where('third_game_orders.player_id', 'like', 'tu%'); else $query->where('third_game_orders.player_id', 'not like', 'tu%'); if (!empty($params['user_id'])) { $userId = (int) $params['user_id']; $query->where(function ($query) use ($userId) { $query->where('users.id', $userId) ->orWhere(function ($query) use ($userId) { $query->whereNull('users.id') ->where('third_game_orders.user_id', $userId); }); }); } if (!empty($params['member_id'])) { $memberId = (string) $params['member_id']; $query->where(function ($query) use ($memberId) { $query->where('users.member_id', $memberId) ->orWhere(function ($query) use ($memberId) { $query->whereNull('users.id') ->where('third_game_orders.member_id', $memberId); }); }); } if (!empty($params['username'])) { $username = '%' . $params['username'] . '%'; $query->where(function ($query) use ($username) { $query->where('users.username', 'like', $username) ->orWhere(function ($query) use ($username) { $query->whereNull('users.id') ->where('third_game_orders.username', 'like', $username); }); }); } if (!empty($params['account'])) { $query->where('users.account', 'like', '%' . trim((string) $params['account']) . '%'); } if (!empty($params['first_name'])) { $firstName = '%' . $params['first_name'] . '%'; $query->where(function ($query) use ($firstName) { $query->where('users.first_name', 'like', $firstName) ->orWhere(function ($query) use ($firstName) { $query->whereNull('users.id') ->where('third_game_orders.first_name', 'like', $firstName); }); }); } // 内部参数:后台实时推送按主键取单行 if (!empty($params['id'])) { $query->where('third_game_orders.id', (int) $params['id']); } if (!empty($params['player_id'])) { $query->where('third_game_orders.player_id', (string) $params['player_id']); } if (!empty($params['platform'])) { $query->where('third_game_orders.platform', (string) $params['platform']); } if (!empty($params['game_order_id'])) { $query->where('third_game_orders.game_order_id', (string) $params['game_order_id']); } if (isset($params['game_type']) && $params['game_type'] !== '') { $query->where('third_game_orders.game_type', (int) $params['game_type']); } if (isset($params['status']) && $params['status'] !== '') { $query->where('third_game_orders.status', (int) $params['status']); } if (!empty($params['start_time']) && !empty($params['end_time'])) { $query->whereBetween('third_game_orders.last_update_time', [ $params['start_time'], $params['end_time'], ]); } $total = (clone $query)->count('third_game_orders.id'); $list = $query ->select('third_game_orders.*') ->addSelect([ 'users.id as current_user_id', 'users.member_id as current_member_id', 'users.username as current_username', 'users.account as current_account', 'users.first_name as current_first_name', ]) ->orderByDesc('third_game_orders.last_update_time') ->orderByDesc('third_game_orders.id') ->forPage($page, $limit) ->get() ->map(fn(ThirdGameOrderModel $order): array => $this->format($order)) ->all(); return [ 'total' => $total, 'page' => $page, 'limit' => $limit, 'list' => $list, 'options' => [ 'game_types' => $this->options(self::GAME_TYPE_NAMES), 'statuses' => $this->options(self::STATUS_NAMES), ], ]; } /** * @return array */ private function usersByPlayerId(array $playerIds): array { $requested = []; $prefix = $this->provider->playerPrefix(); $pattern = '/^' . preg_quote($prefix, '/') . '(\d{1,' . (11 - strlen($prefix)) . '})$/D'; foreach ($playerIds as $playerId) { $playerId = trim((string) $playerId); if (!preg_match($pattern, $playerId, $matches)) { continue; } $userId = filter_var(ltrim($matches[1], '0'), FILTER_VALIDATE_INT, [ 'options' => ['min_range' => 1, 'max_range' => PHP_INT_MAX], ]); if ($userId !== false && ($playerId === $prefix . $userId || $playerId === $this->provider->playerIdFromUserId($userId))) { $requested[(int) $userId][$playerId] = true; } } $result = $this->usersByLegacyPlayerId($playerIds); if ($requested === []) { return $result; } $rows = DB::table('users') ->select(['id', 'member_id', 'username', 'first_name']) ->whereIn('id', array_keys($requested)) ->get(); foreach ($rows as $row) { foreach (array_keys($requested[(int) $row->id] ?? []) as $playerId) { $result[$playerId] = $row; } } return $result; } /** * 2026-08-24 之前的三方账号为 p + md5(sn_member_id) 前10位,历史订单仍使用该账号。 * * @return array */ private function usersByLegacyPlayerId(array $playerIds): array { $legacy = []; foreach ($playerIds as $playerId) { $playerId = trim((string) $playerId); if (preg_match('/^p[0-9a-f]{10}$/D', $playerId)) $legacy[$playerId] = true; } if ($legacy === []) { return []; } $sn = (string) config('third_game.sn'); $expression = "CONCAT('p', SUBSTRING(MD5(CONCAT(?, '_', `member_id`)), 1, 10))"; $placeholders = implode(',', array_fill(0, count($legacy), '?')); $rows = DB::table('users') ->select(['id', 'member_id', 'username', 'first_name']) ->selectRaw($expression . ' AS legacy_player_id', [$sn]) ->whereRaw($expression . " IN ({$placeholders})", array_merge([$sn], array_keys($legacy))) ->get(); $result = []; foreach ($rows as $row) { $result[(string) $row->legacy_player_id] = $row; } return $result; } /** 补齐同步时未能识别旧账号而缺少会员信息的订单。 */ public function backfillLegacyPlayers(): int { $updated = 0; $playerIds = ThirdGameOrderModel::query()->whereNull('user_id')->where('player_id', 'like', 'p%') ->distinct()->pluck('player_id')->all(); foreach (array_chunk($playerIds, 200) as $chunk) { foreach ($this->usersByLegacyPlayerId($chunk) as $playerId => $user) { $ids = ThirdGameOrderModel::query()->whereNull('user_id')->where('player_id', $playerId)->pluck('id')->all(); $updated += ThirdGameOrderModel::query()->whereNull('user_id')->where('player_id', $playerId)->update([ 'user_id' => $user->id, 'member_id' => $user->member_id, 'username' => (string) ($user->username ?? ''), 'first_name' => (string) ($user->first_name ?? ''), ]); AdminRealtime::record('third_game_orders', $ids); } } return $updated; } /** * @return array */ /** 只推送新订单及状态、结算、抽成、会员归属等有实质变化的订单。 */ private function snapshotChanged($previous, array $row): bool { if ($previous === null) { return true; } $settled = $previous->getRawOriginal('settled_amount'); $settledChanged = is_numeric($settled) && is_numeric($row['settled_amount']) ? bccomp((string) $settled, (string) $row['settled_amount'], 10) !== 0 : $settled !== $row['settled_amount']; return $settledChanged || $previous->third_game_commission_ratio !== $row['third_game_commission_ratio'] || $previous->third_game_commission !== $row['third_game_commission'] || $previous->currency !== $row['currency'] || (int) $previous->getRawOriginal('status') !== (int) $row['status'] || (string) $previous->getRawOriginal('last_update_time') !== (string) $row['last_update_time'] || (string) $previous->getRawOriginal('user_id') !== (string) $row['user_id']; } private function recordRealtime(array $changedKeys, array $existing): void { if ($changedKeys === [] || !config('admin_realtime.enabled')) { return; } try { $ids = ThirdGameOrderModel::query()->whereIn('order_key', $changedKeys)->pluck('id', 'order_key')->all(); $created = array_values(array_intersect_key($ids, array_diff_key(array_flip($changedKeys), $existing))); AdminRealtime::record('third_game_orders', $created, 'created'); AdminRealtime::record('third_game_orders', array_values(array_diff($ids, $created))); } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::channel('realtime')->error('admin_realtime_third_game_failed', ['error' => $e->getMessage()]); } } private function format(ThirdGameOrderModel $order): array { $userId = $order->current_user_id ?? $order->user_id; $memberId = $order->current_member_id ?? $order->member_id; $username = $order->current_username ?? $order->username; $firstName = $order->current_first_name ?? $order->first_name; $gameType = (int) $order->game_type; $status = (int) $order->status; $result = ThirdGameCommissionService::result($status, $order->settled_amount); $commission = $result === 'loss' ? $order->third_game_commission : ($status === 1 && $result === null ? null : Money::zero()); return [ 'id' => (int) $order->id, 'user_id' => $userId === null ? null : (int) $userId, 'member_id' => $memberId === null ? null : (string) $memberId, 'username' => (string) ($username ?? ''), 'account' => (string) ($order->current_account ?? ''), 'first_name' => (string) ($firstName ?? ''), 'player_id' => (string) $order->player_id, 'platform' => (string) $order->platform, 'currency' => (string) $order->currency, 'game_type' => $gameType, 'game_type_text' => self::GAME_TYPE_NAMES[$gameType] ?? '未知', 'game_name' => (string) $order->game_name, 'round' => (string) $order->round_no, 'table' => (string) $order->table_no, 'seat' => (string) $order->seat, 'bet_amount' => $this->number($order->bet_amount), 'valid_amount' => $this->number($order->valid_amount), 'settled_amount' => $this->number($order->settled_amount), 'bet_result' => $result, 'third_game_commission_ratio' => $result === 'loss' ? $order->third_game_commission_ratio : null, 'third_game_commission' => $commission, 'bet_content' => (string) ($order->bet_content ?? ''), 'status' => $status, 'status_text' => self::STATUS_NAMES[$status] ?? '未知', 'game_order_id' => (string) $order->game_order_id, 'bet_time' => $this->formatTime($order->bet_time), 'last_update_time' => $this->formatTime($order->last_update_time), ]; } private function identity(string $platform, string $playerId, string $gameOrderId): string { return hash('sha256', $platform . "\0" . $playerId . "\0" . $gameOrderId); } private function decimal($value): ?string { if (!is_numeric($value)) { return null; } $value = trim((string) $value); if (preg_match('/^-?\d+(?:\.\d+)?$/D', $value)) { return bcadd($value, '0', 10); } return number_format((float) $value, 10, '.', ''); } private function text($value): string { if (is_array($value) || is_object($value)) { return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: ''; } return is_scalar($value) ? (string) $value : ''; } private function dateTime($value): ?string { if ($value === null || $value === '') { return null; } try { return Carbon::parse($value, config('app.timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s'); } catch (\Throwable $e) { return null; } } private function formatTime($value): ?string { return $value ? Carbon::parse($value)->format('Y-m-d H:i:s') : null; } private function number($value) { return $value === null || $value === '' ? null : (float) $value; } private function options(array $items): array { $options = []; foreach ($items as $value => $label) { $options[] = ['label' => $label, 'value' => $value]; } return $options; } }