doge 1 день назад
Родитель
Сommit
765006b74b

+ 151 - 0
app/Console/Commands/ThirdGameOrderBackfill.php

@@ -0,0 +1,151 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\ThirdGameOrderService;
+use Carbon\Carbon;
+use Illuminate\Console\Command;
+
+class ThirdGameOrderBackfill extends Command
+{
+    protected $signature = 'third-game:backfill-orders
+        {--days=15 : 未指定时间时回填最近几天,1-15}
+        {--start= : 开始时间,格式 Y-m-d H:i:s}
+        {--end= : 结束时间,格式 Y-m-d H:i:s}';
+
+    protected $description = '分段回填 api-bet 最近 15 天内的三方游戏历史订单';
+
+    public function handle(ThirdGameOrderService $service): int
+    {
+        $timezone = config('app.timezone', 'Asia/Shanghai');
+        $now = Carbon::now($timezone);
+        $startOption = trim((string) $this->option('start'));
+        $endOption = trim((string) $this->option('end'));
+
+        if (($startOption === '') !== ($endOption === '')) {
+            $this->error('--start 和 --end 必须同时提供');
+            return self::FAILURE;
+        }
+
+        try {
+            if ($startOption !== '') {
+                $start = Carbon::createFromFormat('Y-m-d H:i:s', $startOption, $timezone);
+                $end = Carbon::createFromFormat('Y-m-d H:i:s', $endOption, $timezone);
+            } else {
+                $days = (int) $this->option('days');
+                if ($days < 1 || $days > 15) {
+                    $this->error('--days 必须是 1-15');
+                    return self::FAILURE;
+                }
+                $end = $now->copy();
+                $start = $end->copy()->subDays($days);
+                if ($days === 15) {
+                    $start->addMinute();
+                }
+            }
+        } catch (\Throwable $e) {
+            $this->error('时间格式错误,应为 Y-m-d H:i:s');
+            return self::FAILURE;
+        }
+
+        if ($start->gte($end)) {
+            $this->error('开始时间必须小于结束时间');
+            return self::FAILURE;
+        }
+        if ($start->diffInSeconds($end) > 15 * 24 * 60 * 60) {
+            $this->error('历史回填范围不能超过15天');
+            return self::FAILURE;
+        }
+        if ($start->lt($now->copy()->subDays(15))) {
+            $this->error('只能回填最近15天内的订单');
+            return self::FAILURE;
+        }
+
+        $cursor = $start->copy();
+        $totalSynced = 0;
+        $windowCount = 0;
+        $this->info(sprintf(
+            '开始回填三方订单:%s 至 %s',
+            $start->format('Y-m-d H:i:s'),
+            $end->format('Y-m-d H:i:s')
+        ));
+
+        while ($cursor->lt($end)) {
+            $windowEnd = $cursor->copy()->addHours(6);
+            if ($windowEnd->gt($end)) {
+                $windowEnd = $end->copy();
+            }
+
+            $page = 1;
+            do {
+                $result = $this->syncWithRateLimitRetry(
+                    $service,
+                    $cursor->format('Y-m-d H:i:s'),
+                    $windowEnd->format('Y-m-d H:i:s'),
+                    $page
+                );
+                if ($result === null) {
+                    return self::FAILURE;
+                }
+
+                $totalSynced += (int) ($result['synced'] ?? 0);
+                $this->line(sprintf(
+                    '%s - %s page=%d received=%d synced=%d unmatched=%d',
+                    $cursor->format('Y-m-d H:i:s'),
+                    $windowEnd->format('Y-m-d H:i:s'),
+                    $page,
+                    (int) ($result['received'] ?? 0),
+                    (int) ($result['synced'] ?? 0),
+                    (int) ($result['unmatched_orders'] ?? 0)
+                ));
+
+                if (empty($result['has_more'])) {
+                    break;
+                }
+                if ($page >= 1000) {
+                    $this->error('单个时间段分页超过1000页,已停止回填');
+                    return self::FAILURE;
+                }
+
+                sleep(10);
+                $page++;
+            } while (true);
+
+            $windowCount++;
+            $cursor = $windowEnd;
+        }
+
+        $this->info("历史订单回填完成:{$windowCount} 个时间段,写入或更新 {$totalSynced} 条");
+        return self::SUCCESS;
+    }
+
+    private function syncWithRateLimitRetry(
+        ThirdGameOrderService $service,
+        string $startTime,
+        string $endTime,
+        int $page
+    ): ?array {
+        while (true) {
+            try {
+                $result = $service->syncHistory($startTime, $endTime, $page, 2000);
+            } catch (\Throwable $e) {
+                $this->error($e->getMessage());
+                return null;
+            }
+
+            if (!empty($result['ok'])) {
+                return $result;
+            }
+
+            $message = (string) ($result['msg'] ?? '三方订单同步失败');
+            if (!preg_match('/请在(\d+)秒后重试/u', $message, $matches)) {
+                $this->error($message);
+                return null;
+            }
+
+            $waitSeconds = max(1, (int) $matches[1] + 1);
+            $this->warn("{$message},命令将在 {$waitSeconds} 秒后自动继续");
+            sleep($waitSeconds);
+        }
+    }
+}

+ 55 - 0
app/Console/Commands/ThirdGameOrderSync.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\ThirdGameOrderService;
+use Illuminate\Console\Command;
+
+class ThirdGameOrderSync extends Command
+{
+    protected $signature = 'third-game:sync-orders';
+
+    protected $description = '同步 api-bet 最新10分钟(不含当前分钟)的实时游戏订单';
+
+    public function handle(ThirdGameOrderService $service): int
+    {
+        $page = 1;
+        $synced = 0;
+
+        do {
+            try {
+                $result = $service->syncRealtime($page, 2000);
+            } catch (\Throwable $e) {
+                $this->error($e->getMessage());
+                return self::FAILURE;
+            }
+
+            if (empty($result['ok'])) {
+                $this->error((string) ($result['msg'] ?? '三方实时订单同步失败'));
+                return self::FAILURE;
+            }
+
+            $synced += (int) ($result['synced'] ?? 0);
+            $this->line(sprintf(
+                'page=%d received=%d synced=%d unmatched=%d',
+                $page,
+                (int) ($result['received'] ?? 0),
+                (int) ($result['synced'] ?? 0),
+                (int) ($result['unmatched_orders'] ?? 0)
+            ));
+
+            if (empty($result['has_more'])) {
+                break;
+            }
+            if ($page >= 1000) {
+                $this->error('实时订单分页超过1000页,已停止同步');
+                return self::FAILURE;
+            }
+
+            $page++;
+        } while (true);
+
+        $this->info("实时订单同步完成,共写入或更新 {$synced} 条");
+        return self::SUCCESS;
+    }
+}

+ 3 - 0
app/Console/Kernel.php

@@ -26,6 +26,9 @@ class Kernel extends ConsoleKernel
         //      ->onOneServer();       // 如果在多服务器环境
 
         $schedule->command('sport')->dailyAt('23:59:00');
+        $schedule->command('third-game:sync-orders')
+            ->everyMinute()
+            ->withoutOverlapping(5);
     }
 
     /**

+ 4 - 27
app/Http/Controllers/admin/ThirdGameOrder.php

@@ -32,33 +32,9 @@ class ThirdGameOrder extends Controller
                 ],
             ]);
 
-            return $this->success($service->paginate($params));
-        } 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 sync(ThirdGameOrderService $service): JsonResponse
-    {
-        try {
-            $params = request()->validate([
-                'start_time' => ['required', 'date_format:Y-m-d H:i:s'],
-                'end_time' => ['required', 'date_format:Y-m-d H:i:s', 'after_or_equal:start_time'],
-                'page' => ['nullable', 'integer', 'min:1'],
-                'limit' => ['nullable', 'integer', 'min:1', 'max:2000'],
-            ]);
-            $result = $service->sync(
-                (string) $params['start_time'],
-                (string) $params['end_time'],
-                (int) ($params['page'] ?? 1),
-                (int) ($params['limit'] ?? 2000)
-            );
-            if (!$result['ok']) {
-                throw new Exception($result['msg'], HttpStatus::CUSTOM_ERROR);
-            }
-            unset($result['ok']);
+            $sync = $service->syncForList($params);
+            $result = $service->paginate($params);
+            $result['sync'] = $sync;
 
             return $this->success($result);
         } catch (ValidationException $e) {
@@ -67,4 +43,5 @@ class ThirdGameOrder extends Controller
             return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
         }
     }
+
 }

+ 97 - 0
app/Services/ThirdGameBalanceService.php

@@ -20,6 +20,8 @@ class ThirdGameBalanceService
 
     private const HISTORY_HOURLY_LIMIT = 5;
 
+    private const REALTIME_QUERY_INTERVAL_SECONDS = 60;
+
     private const GAME_TYPE_NAMES = [
         1 => '视讯',
         2 => '老虎机',
@@ -218,6 +220,54 @@ class ThirdGameBalanceService
         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,
+        ];
+    }
+
     /**
      * 一键把用户在所有三方游戏平台的余额转出。
      */
@@ -524,6 +574,38 @@ class ThirdGameBalanceService
         }
     }
 
+    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') !== ''
@@ -569,6 +651,11 @@ class ThirdGameBalanceService
         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);
@@ -579,6 +666,16 @@ class ThirdGameBalanceService
         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));

+ 36 - 2
app/Services/ThirdGameOrderService.php

@@ -32,13 +32,24 @@ class ThirdGameOrderService
         $this->provider = $provider;
     }
 
-    public function sync(
+    public function syncHistory(
         string $startTime,
         string $endTime,
         int $page = 1,
         int $limit = 2000
     ): array {
-        $result = $this->provider->historyOrders($startTime, $endTime, $page, $limit);
+        return $this->persistProviderResult(
+            $this->provider->historyOrders($startTime, $endTime, $page, $limit)
+        );
+    }
+
+    public function syncRealtime(int $page = 1, int $limit = 2000): array
+    {
+        return $this->persistProviderResult($this->provider->realtimeOrders($page, $limit));
+    }
+
+    private function persistProviderResult(array $result): array
+    {
         if (empty($result['ok'])) {
             return $result;
         }
@@ -126,6 +137,29 @@ class ThirdGameOrderService
         ];
     }
 
+    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));

+ 9 - 34
docs/admin-third-game-order-api.md

@@ -10,7 +10,7 @@ Authorization: Bearer <token>
 
 ### GET `/admin/thirdGame/orders`
 
-列表读取系统数据库,不会直接请求三方
+列表读取系统数据库。后端每分钟通过实时记录接口同步最新 10 分钟订单(不含当前分钟);查询第一页时也会尝试同步一次,本地翻页不会重复同步
 
 | 参数 | 必填 | 说明 |
 | --- | --- | --- |
@@ -65,42 +65,17 @@ Authorization: Bearer <token>
     "options": {
       "game_types": [],
       "statuses": []
+    },
+    "sync": {
+      "attempted": true,
+      "ok": true,
+      "message": "",
+      "synced": 100
     }
   }
 }
 ```
 
-## 2. 同步三方订单
+前端不需要调用同步接口。三方限频时可能返回 `sync.ok = false`,但订单列表仍然有效,前端继续展示 `list` 即可。
 
-### POST `/admin/thirdGame/orders/sync`
-
-```json
-{
-  "start_time": "2026-08-21 00:00:00",
-  "end_time": "2026-08-21 06:00:00",
-  "page": 1,
-  "limit": 2000
-}
-```
-
-单次时间范围最多 6 小时,只能同步最近 15 天。返回:
-
-```json
-{
-  "code": 0,
-  "data": {
-    "received": 100,
-    "synced": 100,
-    "matched_users": 20,
-    "unmatched_orders": 0,
-    "total": 100,
-    "page_no": 1,
-    "page_size": 2000,
-    "has_more": false
-  }
-}
-```
-
-同步成功后重新请求订单列表。`has_more = true` 时等待至少 10 秒,再使用相同时间范围请求下一页。
-
-普通同步至少间隔 1 分钟且每小时最多 5 次;翻页同步至少间隔 10 秒。失败时 `code != 0`,原因读取 `msg`。
+接口请求失败时 `code != 0`,原因读取 `msg`。

+ 0 - 1
routes/admin.php

@@ -221,7 +221,6 @@ Route::middleware(['admin.jwt'])->group(function () {
 
         Route::prefix('/thirdGame')->group(function () {
             Route::get('/orders', [ThirdGameOrder::class, 'index']);
-            Route::post('/orders/sync', [ThirdGameOrder::class, 'sync']);
         });
 
         Route::prefix('/wallet')->group(function () {