doge 2 недель назад
Родитель
Сommit
c4d2604b96

+ 5 - 6
app/Http/Controllers/admin/Egame.php

@@ -71,10 +71,9 @@ class Egame extends Controller
                 'game_type' => ['required', 'integer', 'min:0', 'max:255'],
                 'game_code' => ['nullable', 'string', 'max:128'],
                 'name' => ['nullable', 'string', 'max:128'],
+                'ingress' => ['nullable', 'string', 'max:16'],
                 'logo' => ['nullable', 'string', 'max:500'],
-                'image_circle' => ['nullable', 'string', 'max:500'],
-                'image_rectangle' => ['nullable', 'string', 'max:500'],
-                'image_square' => ['nullable', 'string', 'max:500'],
+                'logo_langs' => ['nullable', 'array'],
                 'status' => ['nullable', 'integer', Rule::in([0, 1])],
                 'sort' => ['nullable', 'integer', 'min:-999999', 'max:999999'],
             ]);
@@ -85,16 +84,16 @@ class Egame extends Controller
                 'game_type' => (int)request()->input('game_type'),
                 'game_code' => trim((string)request()->input('game_code', '')),
                 'name' => trim((string)request()->input('name', '')),
+                'ingress' => trim((string)request()->input('ingress', '3')),
                 'logo' => trim((string)request()->input('logo', '')),
-                'image_circle' => trim((string)request()->input('image_circle', '')),
-                'image_rectangle' => trim((string)request()->input('image_rectangle', '')),
-                'image_square' => trim((string)request()->input('image_square', '')),
+                'logo_langs' => request()->input('logo_langs', null),
                 'status' => (int)request()->input('status', 1),
                 'sort' => (int)request()->input('sort', 0),
             ];
 
             if ($data['item_type'] === 'platform') {
                 $data['game_code'] = '';
+                $data['ingress'] = '3';
             }
             if ($data['item_type'] === 'game' && $data['game_code'] === '') {
                 throw new Exception('游戏配置必须填写 game_code', HttpStatus::CUSTOM_ERROR);

+ 297 - 0
app/Http/Controllers/admin/User.php

@@ -17,7 +17,12 @@ use Illuminate\Http\JsonResponse;
 use App\Models\User as UserModel;
 use App\Models\UserSession;
 use App\Models\UserLogin;
+use App\Models\ThirdGameRecycle;
+use App\Models\Wallet;
+use App\Services\BalanceLogService;
+use App\Services\ThirdGameBalanceService;
 use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Str;
 
 class User extends Controller
 {
@@ -126,6 +131,298 @@ class User extends Controller
         return $this->success($result);
     }
 
+    /**
+     * 查询指定用户在 ag、pg 等三方游戏平台的余额明细。
+     */
+    public function thirdGameDetail(ThirdGameBalanceService $service): JsonResponse
+    {
+        try {
+            $params = request()->validate([
+                'member_id' => ['required', 'string', 'min:1'],
+            ]);
+            if (!UserModel::where('member_id', $params['member_id'])->exists()) {
+                throw new Exception('用户不存在', HttpStatus::CUSTOM_ERROR);
+            }
+
+            $result = $service->detail((string) $params['member_id']);
+            if (!$result['ok']) {
+                throw new Exception($result['msg'], HttpStatus::CUSTOM_ERROR);
+            }
+            unset($result['ok']);
+            return $this->success($result);
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+
+    /**
+     * 一键回收指定用户的全部三方游戏余额并入账主钱包。
+     */
+    public function recycleThirdGameBalance(ThirdGameBalanceService $service): JsonResponse
+    {
+        $lock = null;
+        $lockAcquired = false;
+        try {
+            $params = request()->validate([
+                'member_id' => ['required', 'string', 'min:1'],
+            ]);
+            $memberId = (string) $params['member_id'];
+            if (!UserModel::where('member_id', $memberId)->exists()) {
+                throw new Exception('用户不存在', HttpStatus::CUSTOM_ERROR);
+            }
+            if (!Wallet::where('member_id', $memberId)->exists()) {
+                throw new Exception('用户钱包不存在', HttpStatus::CUSTOM_ERROR);
+            }
+
+            $lock = Cache::lock('admin_third_game_recycle:' . $memberId, 120);
+            $lockAcquired = $lock->get();
+            if (!$lockAcquired) {
+                throw new Exception('该用户正在回收三方余额,请勿重复操作', HttpStatus::CUSTOM_ERROR);
+            }
+
+            $pendingCredit = ThirdGameRecycle::where('member_id', $memberId)
+                ->where('status', ThirdGameRecycle::STATUS_PROVIDER_SUCCEEDED)
+                ->orderBy('id')
+                ->first();
+            if ($pendingCredit) {
+                try {
+                    $credited = $this->creditThirdGameRecycle($pendingCredit->id);
+                } catch (Exception $e) {
+                    throw new Exception(
+                        '三方余额已回收但钱包补入账失败,流水号:' . $pendingCredit->operation_id,
+                        HttpStatus::CUSTOM_ERROR
+                    );
+                }
+                $credited['recovered_pending_operation'] = true;
+                return $this->success($credited);
+            }
+
+            $unresolved = ThirdGameRecycle::where('member_id', $memberId)
+                ->whereIn('status', [
+                    ThirdGameRecycle::STATUS_PENDING,
+                    ThirdGameRecycle::STATUS_UNCERTAIN,
+                ])
+                ->orderByDesc('id')
+                ->first();
+            if ($unresolved) {
+                throw new Exception(
+                    '存在待核对的三方回收流水:' . $unresolved->operation_id . ',请先人工核对三方账单',
+                    HttpStatus::CUSTOM_ERROR
+                );
+            }
+
+            $operation = ThirdGameRecycle::create([
+                'operation_id' => (string) Str::uuid(),
+                'member_id' => $memberId,
+                'player_id' => $service->playerId($memberId),
+                'status' => ThirdGameRecycle::STATUS_PENDING,
+            ]);
+
+            $recycled = $service->recycle($memberId);
+            if (!$recycled['ok']) {
+                $operation->status = !empty($recycled['uncertain'])
+                    ? ThirdGameRecycle::STATUS_UNCERTAIN
+                    : ThirdGameRecycle::STATUS_FAILED;
+                $operation->error = $recycled['msg'];
+                $operation->save();
+                throw new Exception(
+                    $recycled['msg'] . ',流水号:' . $operation->operation_id,
+                    HttpStatus::CUSTOM_ERROR
+                );
+            }
+
+            $operation->status = ThirdGameRecycle::STATUS_PROVIDER_SUCCEEDED;
+            $operation->game_balance = $recycled['game_balance'];
+            $operation->wallet_balance = $recycled['wallet_balance'];
+            $operation->save();
+
+            try {
+                $credited = $this->creditThirdGameRecycle($operation->id);
+            } catch (Exception $e) {
+                throw new Exception(
+                    '三方余额已回收但钱包入账失败,请重试回收以补入账,流水号:' . $operation->operation_id,
+                    HttpStatus::CUSTOM_ERROR
+                );
+            }
+            return $this->success($credited);
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        } finally {
+            if ($lockAcquired && $lock) {
+                $lock->release();
+            }
+        }
+    }
+
+    /**
+     * 将三方已回收的流水幂等入账;事务失败时保留 provider_succeeded 供下次补偿。
+     */
+    private function creditThirdGameRecycle(int $operationId): array
+    {
+        return DB::transaction(function () use ($operationId) {
+            $operation = ThirdGameRecycle::where('id', $operationId)->lockForUpdate()->firstOrFail();
+            if ($operation->status === ThirdGameRecycle::STATUS_COMPLETED) {
+                $walletBalance = (float) Wallet::where('member_id', $operation->member_id)
+                    ->value('available_balance');
+                return $this->recycleResponse($operation, $walletBalance);
+            }
+            if ($operation->status !== ThirdGameRecycle::STATUS_PROVIDER_SUCCEEDED) {
+                throw new Exception('三方回收流水状态不可入账', HttpStatus::CUSTOM_ERROR);
+            }
+
+            $wallet = Wallet::where('member_id', $operation->member_id)->lockForUpdate()->firstOrFail();
+            $before = (string) $wallet->available_balance;
+            $walletGain = (string) $operation->wallet_balance;
+            $after = bcadd($before, $walletGain, 10);
+            if (bccomp($walletGain, '0', 10) > 0) {
+                BalanceLogService::addLog(
+                    $operation->member_id,
+                    $walletGain,
+                    $before,
+                    $after,
+                    '三方游戏转出',
+                    $operation->id,
+                    '后台一键回收游戏余额'
+                );
+                $wallet->available_balance = $after;
+                $wallet->save();
+            }
+
+            $operation->status = ThirdGameRecycle::STATUS_COMPLETED;
+            $operation->credited_at = now();
+            $operation->save();
+
+            return $this->recycleResponse($operation, (float) $after);
+        });
+    }
+
+    private function recycleResponse(ThirdGameRecycle $operation, float $walletBalance): array
+    {
+        return [
+            'operation_id' => $operation->operation_id,
+            'member_id' => $operation->member_id,
+            'recovered_game_balance' => (float) $operation->game_balance,
+            'recovered_balance' => (float) $operation->wallet_balance,
+            'wallet_balance' => $walletBalance,
+        ];
+    }
+
+    /**
+     * 查询三方游戏回收流水,用于核对 pending/uncertain 状态。
+     */
+    public function thirdGameRecycleRecords(): JsonResponse
+    {
+        try {
+            $params = request()->validate([
+                'page' => ['nullable', 'integer', 'min:1'],
+                'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'member_id' => ['nullable', 'string', 'min:1'],
+                'status' => ['nullable', 'string', 'in:pending,provider_succeeded,completed,failed,uncertain'],
+            ]);
+            $page = (int) ($params['page'] ?? 1);
+            $limit = (int) ($params['limit'] ?? 15);
+            $query = ThirdGameRecycle::query()
+                ->when(!empty($params['member_id']), function ($query) use ($params) {
+                    $query->where('member_id', $params['member_id']);
+                })
+                ->when(!empty($params['status']), function ($query) use ($params) {
+                    $query->where('status', $params['status']);
+                });
+
+            $total = (clone $query)->count();
+            $list = $query->orderByDesc('id')->forPage($page, $limit)->get();
+            return $this->success(['total' => $total, 'data' => $list]);
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+
+    /**
+     * 人工核对待确认流水:确认未回收,或按三方实际金额补入账。
+     */
+    public function resolveThirdGameRecycle(ThirdGameBalanceService $service): JsonResponse
+    {
+        $lock = null;
+        $lockAcquired = false;
+        try {
+            $params = request()->validate([
+                'operation_id' => ['required', 'uuid'],
+                'action' => ['required', 'string', 'in:failed,credit'],
+                'game_balance' => ['nullable', 'required_if:action,credit', 'numeric', 'min:0'],
+                'remark' => ['nullable', 'string', 'max:500'],
+            ]);
+            $operation = ThirdGameRecycle::where('operation_id', $params['operation_id'])->first();
+            if (!$operation) {
+                throw new Exception('三方回收流水不存在', HttpStatus::CUSTOM_ERROR);
+            }
+
+            $lock = Cache::lock('admin_third_game_recycle:' . $operation->member_id, 120);
+            $lockAcquired = $lock->get();
+            if (!$lockAcquired) {
+                throw new Exception('该用户正在处理三方回收,请稍后再试', HttpStatus::CUSTOM_ERROR);
+            }
+
+            $operation = DB::transaction(function () use ($operation, $params, $service) {
+                $operation = ThirdGameRecycle::where('id', $operation->id)->lockForUpdate()->firstOrFail();
+                if (!in_array($operation->status, [
+                    ThirdGameRecycle::STATUS_PENDING,
+                    ThirdGameRecycle::STATUS_UNCERTAIN,
+                ], true)) {
+                    throw new Exception('当前流水状态无需人工处理', HttpStatus::CUSTOM_ERROR);
+                }
+
+                $remark = trim((string) ($params['remark'] ?? ''));
+                if ($params['action'] === 'failed') {
+                    $operation->status = ThirdGameRecycle::STATUS_FAILED;
+                    $operation->resolution_remark = '后台人工核对:确认三方未回收'
+                        . ($remark !== '' ? ';' . $remark : '');
+                } else {
+                    $gameBalance = (float) $params['game_balance'];
+                    $operation->status = ThirdGameRecycle::STATUS_PROVIDER_SUCCEEDED;
+                    $operation->game_balance = $gameBalance;
+                    $operation->wallet_balance = $service->toWalletBalance($gameBalance);
+                    $operation->resolution_remark = '后台人工核对:确认三方已回收'
+                        . ($remark !== '' ? ';' . $remark : '');
+                }
+                $operation->save();
+                return $operation;
+            });
+
+            if ($params['action'] === 'failed') {
+                return $this->success([
+                    'operation_id' => $operation->operation_id,
+                    'member_id' => $operation->member_id,
+                    'status' => $operation->status,
+                ]);
+            }
+
+            try {
+                $credited = $this->creditThirdGameRecycle($operation->id);
+            } catch (Exception $e) {
+                throw new Exception(
+                    '核对金额已保存但钱包入账失败,请再次提交回收操作补入账,流水号:' . $operation->operation_id,
+                    HttpStatus::CUSTOM_ERROR
+                );
+            }
+            $credited['resolved_manually'] = true;
+            return $this->success($credited);
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        } finally {
+            if ($lockAcquired && $lock) {
+                $lock->release();
+            }
+        }
+    }
+
     public function merge(): JsonResponse
     {
         DB::beginTransaction();

+ 6 - 3
app/Models/EgameItem.php

@@ -12,11 +12,14 @@ class EgameItem extends BaseModel
         'game_type',
         'game_code',
         'name',
+        'ingress',
         'logo',
-        'image_circle',
-        'image_rectangle',
-        'image_square',
+        'logo_langs',
         'status',
         'sort',
     ];
+
+    protected $casts = [
+        'logo_langs' => 'array',
+    ];
 }

+ 34 - 0
app/Models/ThirdGameRecycle.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Models;
+
+class ThirdGameRecycle extends BaseModel
+{
+    public const STATUS_PENDING = 'pending';
+    public const STATUS_PROVIDER_SUCCEEDED = 'provider_succeeded';
+    public const STATUS_COMPLETED = 'completed';
+    public const STATUS_FAILED = 'failed';
+    public const STATUS_UNCERTAIN = 'uncertain';
+
+    protected $table = 'third_game_recycles';
+
+    protected $hidden = [];
+
+    protected $fillable = [
+        'operation_id',
+        'member_id',
+        'player_id',
+        'status',
+        'game_balance',
+        'wallet_balance',
+        'error',
+        'resolution_remark',
+        'credited_at',
+    ];
+
+    protected $casts = [
+        'game_balance' => 'decimal:10',
+        'wallet_balance' => 'decimal:10',
+        'credited_at' => 'datetime',
+    ];
+}

+ 342 - 0
app/Services/ThirdGameBalanceService.php

@@ -0,0 +1,342 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Http\Client\Pool;
+use Illuminate\Http\Client\Response;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+
+class ThirdGameBalanceService
+{
+    private const CODE_SUCCESS = 10000;
+
+    /**
+     * 批量查询用户的三方游戏总余额,返回值已按 GAME_RATE 换算为主钱包货币。
+     *
+     * @param array<int, string|int> $memberIds
+     * @return array<string, float|null>
+     */
+    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): array
+    {
+        $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 recycle(string $memberId): array
+    {
+        $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 = (float) $data['balanceAll'];
+            $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.0, 'wallet_balance' => 0.0];
+        }
+
+        return [
+            'ok' => false,
+            'uncertain' => false,
+            'msg' => $this->responseMessage($response),
+        ];
+    }
+
+    public function toWalletBalance($gameBalance): float
+    {
+        return round((float) $gameBalance / $this->rate(), 2);
+    }
+
+    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 = array_sum(array_filter($balances, 'is_numeric'));
+        $walletBalances = [];
+        foreach ($balances as $platform => $balance) {
+            if (is_numeric($balance)) {
+                $walletBalances[$platform] = round((float) $balance / $this->rate(), 2);
+            }
+        }
+        return [
+            'ok' => true,
+            'member_id' => $memberId,
+            'player_id' => $this->playerId($memberId),
+            'currency' => (string) config('third_game.currency', 'CNY'),
+            'rate' => $this->rate(),
+            'list' => $walletBalances,
+            'game_list' => $balances,
+            'total_game_balance' => round($gameBalance, 2),
+            'total_balance' => $this->totalFromBalances($balances),
+        ];
+    }
+
+    private function totalFromBalances(array $balances): float
+    {
+        $gameBalance = array_sum(array_filter($balances, 'is_numeric'));
+        return round($gameBalance / $this->rate(), 2);
+    }
+
+    private function rate(): float
+    {
+        $rate = (float) config('third_game.rate', 10);
+        return $rate > 0 ? $rate : 1;
+    }
+
+    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 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 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;
+    }
+}

+ 5 - 0
app/Services/UserService.php

@@ -134,8 +134,13 @@ class UserService extends BaseService
             ->select("users.*")
             ->forPage($page, $limit)
             ->get();
+
+        $thirdGameBalances = app(ThirdGameBalanceService::class)->totals(
+            $list->pluck('member_id')->all()
+        );
         foreach($list as &$item) {
             $item['total_consume'] = BalanceLog::getTotalConsume($item->member_id);//用户累计消费总额
+            $item['third_game_total_balance'] = $thirdGameBalances[(string) $item->member_id] ?? null;
         }
         return ['total' => $total, 'data' => $list];
     }

+ 14 - 0
config/third_game.php

@@ -0,0 +1,14 @@
+<?php
+
+return [
+    'api_url' => rtrim((string) env('GAME_API_URL', 'https://ap.api-bet.net'), '/'),
+    'sn' => trim((string) env('GAME_SN', '')),
+    'key' => trim((string) env('GAME_KEY', '')),
+    'proxy' => trim((string) env('GAME_PROXY', '')),
+    'ca_bundle' => trim((string) env('GAME_CA_BUNDLE', '')),
+    'currency' => trim((string) env('GAME_CURRENCY', 'CNY')),
+    'rate' => (float) env('GAME_RATE', 10),
+    'timeout' => (int) env('GAME_TIMEOUT', 10),
+    'transfer_timeout' => (int) env('GAME_TRANSFER_TIMEOUT', 65),
+    'cache_seconds' => (int) env('GAME_BALANCE_CACHE_SECONDS', 30),
+];

+ 3 - 4
database/migrations/2026_07_08_120000_create_egame_items_table.php

@@ -18,10 +18,9 @@ return new class extends Migration {
             $table->unsignedTinyInteger('game_type')->default(0)->comment('游戏类型');
             $table->string('game_code', 128)->default('')->comment('三方游戏代码,平台行为空');
             $table->string('name', 128)->default('')->comment('展示名称');
-            $table->string('logo', 500)->default('')->comment('平台logo');
-            $table->string('image_circle', 500)->default('')->comment('圆形游戏图');
-            $table->string('image_rectangle', 500)->default('')->comment('矩形游戏图');
-            $table->string('image_square', 500)->default('')->comment('方形游戏图');
+            $table->string('ingress', 16)->default('3')->comment('1电脑 2手机 3通用');
+            $table->string('logo', 500)->default('')->comment('平台logo/游戏square图');
+            $table->json('logo_langs')->nullable()->comment('游戏多语言square图');
             $table->unsignedTinyInteger('status')->default(1)->comment('1开启 0关闭');
             $table->integer('sort')->default(0)->comment('排序,越大越靠前');
             $table->timestamps();

+ 25 - 0
database/migrations/2026_07_08_121000_add_ingress_to_egame_items_table.php

@@ -0,0 +1,25 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up()
+    {
+        if (Schema::hasTable('egame_items') && !Schema::hasColumn('egame_items', 'ingress')) {
+            Schema::table('egame_items', function (Blueprint $table) {
+                $table->string('ingress', 16)->default('3')->after('name')->comment('1电脑 2手机 3通用');
+            });
+        }
+    }
+
+    public function down()
+    {
+        if (Schema::hasTable('egame_items') && Schema::hasColumn('egame_items', 'ingress')) {
+            Schema::table('egame_items', function (Blueprint $table) {
+                $table->dropColumn('ingress');
+            });
+        }
+    }
+};

+ 52 - 0
database/migrations/2026_07_08_122000_remove_image_fields_from_egame_items_table.php

@@ -0,0 +1,52 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up()
+    {
+        if (!Schema::hasTable('egame_items')) {
+            return;
+        }
+
+        if (Schema::hasColumn('egame_items', 'image_square')) {
+            DB::table('egame_items')
+                ->where('item_type', 'game')
+                ->where(function ($query) {
+                    $query->whereNull('logo')->orWhere('logo', '');
+                })
+                ->where('image_square', '<>', '')
+                ->update(['logo' => DB::raw('image_square')]);
+        }
+
+        Schema::table('egame_items', function (Blueprint $table) {
+            foreach (['image_circle', 'image_rectangle', 'image_square'] as $column) {
+                if (Schema::hasColumn('egame_items', $column)) {
+                    $table->dropColumn($column);
+                }
+            }
+        });
+    }
+
+    public function down()
+    {
+        if (!Schema::hasTable('egame_items')) {
+            return;
+        }
+
+        Schema::table('egame_items', function (Blueprint $table) {
+            if (!Schema::hasColumn('egame_items', 'image_circle')) {
+                $table->string('image_circle', 500)->default('')->comment('圆形游戏图');
+            }
+            if (!Schema::hasColumn('egame_items', 'image_rectangle')) {
+                $table->string('image_rectangle', 500)->default('')->comment('矩形游戏图');
+            }
+            if (!Schema::hasColumn('egame_items', 'image_square')) {
+                $table->string('image_square', 500)->default('')->comment('方形游戏图');
+            }
+        });
+    }
+};

+ 29 - 0
database/migrations/2026_07_08_123000_add_logo_langs_to_egame_items_table.php

@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up()
+    {
+        if (!Schema::hasTable('egame_items') || Schema::hasColumn('egame_items', 'logo_langs')) {
+            return;
+        }
+
+        Schema::table('egame_items', function (Blueprint $table) {
+            $table->json('logo_langs')->nullable()->after('logo')->comment('游戏多语言square图');
+        });
+    }
+
+    public function down()
+    {
+        if (!Schema::hasTable('egame_items') || !Schema::hasColumn('egame_items', 'logo_langs')) {
+            return;
+        }
+
+        Schema::table('egame_items', function (Blueprint $table) {
+            $table->dropColumn('logo_langs');
+        });
+    }
+};

+ 35 - 0
database/migrations/2026_07_13_120000_create_third_game_recycles_table.php

@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up()
+    {
+        if (Schema::hasTable('third_game_recycles')) {
+            return;
+        }
+
+        Schema::create('third_game_recycles', function (Blueprint $table) {
+            $table->id();
+            $table->uuid('operation_id')->unique();
+            $table->string('member_id', 64)->index();
+            $table->string('player_id', 16);
+            $table->string('status', 32)->default('pending');
+            $table->decimal('game_balance', 30, 10)->nullable();
+            $table->decimal('wallet_balance', 30, 10)->nullable();
+            $table->text('error')->nullable();
+            $table->text('resolution_remark')->nullable();
+            $table->timestamp('credited_at')->nullable();
+            $table->timestamps();
+
+            $table->index(['member_id', 'status'], 'idx_third_game_recycle_member_status');
+        });
+    }
+
+    public function down()
+    {
+        Schema::dropIfExists('third_game_recycles');
+    }
+};

+ 13 - 1
example.env

@@ -28,6 +28,19 @@ API_BASKETBALL_HOST=https://v1.basketball.api-sports.io
 # 是否发送电报
 SEND_TELEGRAM = true
 
+# 三方游戏(需与 melbet_sport-api 的 GAME 配置保持一致)
+GAME_API_URL=https://ap.api-bet.net
+GAME_SN=
+GAME_KEY=
+GAME_PROXY=
+# 代理使用自签发 CA 时填写 CA 证书绝对路径;留空使用系统 CA
+GAME_CA_BUNDLE=
+GAME_CURRENCY=CNY
+GAME_RATE=10
+GAME_TIMEOUT=10
+GAME_TRANSFER_TIMEOUT=65
+GAME_BALANCE_CACHE_SECONDS=30
+
 # 短信宝
 SMS_USERNAME=
 SMS_PASSWORD=
@@ -177,4 +190,3 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
 VITE_PUSHER_PORT="${PUSHER_PORT}"
 VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
 VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
-

+ 4 - 2
routes/admin.php

@@ -234,6 +234,10 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::get('/loginLog', [User::class, 'loginLog']);
             Route::post('/setRechargeChannelGroup', [User::class, 'setRechargeChannelGroup']);
             Route::post('/setPassword', [User::class, 'setPassword']);
+            Route::get('/thirdGame/detail', [User::class, 'thirdGameDetail']);
+            Route::post('/thirdGame/recycle', [User::class, 'recycleThirdGameBalance']);
+            Route::get('/thirdGame/recycleRecords', [User::class, 'thirdGameRecycleRecords']);
+            Route::post('/thirdGame/recycleResolve', [User::class, 'resolveThirdGameRecycle']);
 
 
         });
@@ -351,5 +355,3 @@ Route::middleware(['admin.jwt'])->group(function () {
 
 
 });
-
-