Browse Source

后台首页商户余额与实时推送

doge 21 hours ago
parent
commit
4c849dd1e1

+ 19 - 0
app/Console/Commands/RefreshThirdGameMerchantBalance.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\ThirdGameBalanceService;
+use Illuminate\Console\Command;
+
+class RefreshThirdGameMerchantBalance extends Command
+{
+    protected $signature = 'third-game:refresh-merchant-balance';
+
+    protected $description = '刷新第三方游戏商户余额快照';
+
+    public function handle(ThirdGameBalanceService $service): int
+    {
+        $service->refreshMerchantBalance();
+        return self::SUCCESS;
+    }
+}

+ 1 - 0
app/Console/Kernel.php

@@ -41,6 +41,7 @@ class Kernel extends ConsoleKernel
             ->withoutOverlapping(180);
         $schedule->command('share:sync-facts --write')->everyMinute()->withoutOverlapping(30);
         $schedule->command('agent:profit-sync')->everyMinute()->withoutOverlapping(30);
+        $schedule->command('third-game:refresh-merchant-balance')->everyMinute()->withoutOverlapping(2)->runInBackground();
         $schedule->command('report:member-daily')->everyTenMinutes()->withoutOverlapping(30);
         $schedule->command('report:member-daily ' . now()->subDay()->toDateString() . ' --days=3')
             ->dailyAt('04:30')->withoutOverlapping(60);

+ 6 - 0
app/Http/Controllers/admin/Home.php

@@ -10,6 +10,7 @@ use App\Models\PaymentOrder;
 use App\Models\Recharge;
 use App\Models\User;
 use App\Models\BalanceLog;
+use App\Services\ThirdGameBalanceService;
 use Illuminate\Validation\ValidationException;
 use Exception;
 use Carbon\Carbon;
@@ -17,6 +18,11 @@ use App\Models\Withdraw;
 
 class Home extends Controller
 {
+    public function thirdGameBalance(ThirdGameBalanceService $service)
+    {
+        return $this->success($service->merchantBalance());
+    }
+
     public function index()
     {
 

+ 1 - 0
app/Services/AdminRealtime/AdminRealtimeRows.php

@@ -50,6 +50,7 @@ final class AdminRealtimeRows
         'admin.users.online' => ['/admin/online/users', 'admin/online/users', 'member_id'],
         'admin.stats.platform_count' => ['/admin/online/platformCount', 'admin/online/platformCount', 'scope'],
         'admin.stats.unprocessed' => ['/admin/paymentOrder/unProcessed', 'admin/paymentOrder/unProcessed', 'scope'],
+        'admin.stats.third_game_balance' => ['/admin/home/third-game-balance', 'admin/home', 'scope'],
         'admin.agents.applications' => ['/admin/agent/applications', 'admin/agent', 'id'],
         'admin.agents.plan_applications' => ['/admin/agent/plan-applications', 'admin/agent/plan-applications', 'id'],
         AdminRealtimeExchangeList::CHANNEL => ['/admin/operation/exchangeList', 'admin/operation/exchangeList', 'scope'],

+ 7 - 2
app/Services/AdminRealtime/AdminRealtimeSummaries.php

@@ -3,6 +3,7 @@
 namespace App\Services\AdminRealtime;
 
 use App\Constants\HttpStatus;
+use App\Http\Controllers\admin\Home;
 use App\Http\Controllers\admin\Online;
 use App\Http\Controllers\admin\PaymentOrder;
 use Illuminate\Http\JsonResponse;
@@ -17,6 +18,7 @@ final class AdminRealtimeSummaries
     public const DEFINITIONS = [
         'online_platform_count' => ['admin.stats.platform_count', Online::class, 'platformCount'],
         'payment_unprocessed' => ['admin.stats.unprocessed', PaymentOrder::class, 'unProcessed'],
+        'third_game_balance' => ['admin.stats.third_game_balance', Home::class, 'thirdGameBalance'],
     ];
 
     public static function read(string $resource): array
@@ -38,9 +40,12 @@ final class AdminRealtimeSummaries
         foreach (array_keys(self::DEFINITIONS) as $resource) {
             try {
                 if (!Cache::add('admin_realtime:summary_scan:' . $resource, 1, $interval)) continue;
-                $signature = hash('sha256', json_encode(self::read($resource), JSON_THROW_ON_ERROR));
+                $snapshot = self::read($resource);
                 $key = 'admin_realtime:summary_snapshot:' . $resource;
-                if (Cache::get($key) === $signature) continue;
+                $previous = Cache::get($key);
+                if ($resource === 'third_game_balance' && $snapshot['balance'] === null && $previous === null) continue;
+                $signature = hash('sha256', json_encode($snapshot, JSON_THROW_ON_ERROR));
+                if ($previous === $signature) continue;
 
                 // 必须先确认事件登记成功再记住快照;失败或崩溃最多重复,不会吞掉变化。
                 if (!DB::table('admin_realtime_events')->insert([

+ 75 - 9
app/Services/ThirdGameBalanceService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use App\Models\User;
 use Carbon\Carbon;
+use Illuminate\Contracts\Cache\LockTimeoutException;
 use Illuminate\Http\Client\Pool;
 use Illuminate\Http\Client\Response;
 use Illuminate\Support\Facades\Cache;
@@ -15,6 +16,8 @@ class ThirdGameBalanceService
 {
     private const CODE_SUCCESS = 10000;
 
+    private const QUOTA_REFRESH_SECONDS = 60;
+
     private const HISTORY_QUERY_INTERVAL_SECONDS = 60;
 
     private const HISTORY_PAGE_INTERVAL_SECONDS = 10;
@@ -46,17 +49,17 @@ class ThirdGameBalanceService
      */
     public function quotaRatios(): array
     {
-        $response = $this->request('/api/server/quota', [
-            'currency' => (string) config('third_game.currency', 'CNY'),
-        ]);
-        if (!$response instanceof Response || !$response->successful()
-            || (int) $response->json('code') !== self::CODE_SUCCESS
-            || !is_array($response->json('data.ratios'))) {
+        $quota = $this->quotaData();
+        if ($quota !== null && $quota['date'] !== now('Asia/Shanghai')->toDateString()) {
+            return ['ok' => false, 'ratios' => [], 'retry_seconds' => self::QUOTA_REFRESH_SECONDS];
+        }
+        $data = $quota['data'] ?? null;
+        if (!is_array($data['ratios'] ?? null)) {
             return ['ok' => false, 'ratios' => []];
         }
 
         $ratios = []; $invalid = [];
-        foreach ($response->json('data.ratios') as $row) {
+        foreach ($data['ratios'] as $row) {
             if (!is_array($row)) continue;
             $name = $row['platfrom'] ?? $row['platform'] ?? $row['platType'] ?? '';
             if (!is_string($name)) continue;
@@ -72,6 +75,69 @@ class ThirdGameBalanceService
         return ['ok' => true, 'ratios' => array_diff_key($ratios, $invalid)];
     }
 
+    /** 首页和 WebSocket 只读取快照,不请求供应商。 */
+    public function merchantBalance(): array
+    {
+        $currency = (string) config('third_game.currency', 'CNY');
+        $snapshot = Cache::get($this->quotaCacheKey($currency) . ':balance');
+        return [
+            'balance' => $snapshot['balance'] ?? null,
+            'currency' => $currency,
+            'stale' => $snapshot !== null && ($snapshot['failed']
+                || now()->getTimestamp() - $snapshot['fetched_at'] >= self::QUOTA_REFRESH_SECONDS * 2),
+        ];
+    }
+
+    public function refreshMerchantBalance(): void
+    {
+        $this->quotaData();
+    }
+
+    /** 商户余额和分成比例共用一次 quota 请求。失败也进入冷却,最后一次有效余额单独保留。 */
+    private function quotaData(): ?array
+    {
+        if (!$this->configured()) return null;
+
+        $currency = (string) config('third_game.currency', 'CNY');
+        $key = $this->quotaCacheKey($currency);
+        $recent = Cache::get($key);
+        if ($recent !== null) return $recent;
+
+        $timeout = max(1, (int) config('third_game.timeout', 10));
+        try {
+            return Cache::lock($key . ':lock', $timeout + 10)->block($timeout + 1, function () use ($key, $currency): ?array {
+                $recent = Cache::get($key);
+                if ($recent !== null) return $recent;
+
+                $startedAt = now('Asia/Shanghai');
+                $response = $this->request('/api/server/quota', ['currency' => $currency]);
+                $data = $response instanceof Response && $response->successful()
+                    && (int) $response->json('code') === self::CODE_SUCCESS ? $response->json('data') : null;
+                if (!is_array($data)) $data = null;
+
+                $balance = $this->decimalAmount($data[$currency] ?? null, signed: true);
+                $snapshotKey = $key . ':balance';
+                if ($balance !== null) {
+                    Cache::forever($snapshotKey, ['balance' => $balance, 'fetched_at' => now()->getTimestamp(), 'failed' => false]);
+                } elseif ($snapshot = Cache::get($snapshotKey)) {
+                    Cache::forever($snapshotKey, array_replace($snapshot, ['failed' => true]));
+                }
+                $recent = ['date' => $startedAt->toDateString(), 'data' => $data];
+                Cache::put($key, $recent, $startedAt->addSeconds(self::QUOTA_REFRESH_SECONDS));
+                return $recent;
+            });
+        } catch (LockTimeoutException $e) {
+            return Cache::get($key);
+        }
+    }
+
+    private function quotaCacheKey(string $currency): string
+    {
+        return 'third_game:merchant_quota:' . hash('sha256', json_encode([
+            config('third_game.api_url'), config('third_game.sn'), config('third_game.key'), $currency,
+        ], JSON_THROW_ON_ERROR));
+    }
+
     /**
      * 批量查询用户的三方游戏总余额,三方与主钱包金额按 1:1 处理。
      *
@@ -555,14 +621,14 @@ class ThirdGameBalanceService
         return $total;
     }
 
-    private function decimalAmount($value): ?string
+    private function decimalAmount($value, bool $signed = false): ?string
     {
         if (!is_int($value) && !is_float($value) && !is_string($value)) {
             return null;
         }
 
         $value = trim((string) $value);
-        if (!preg_match('/^\d+(?:\.\d+)?$/D', $value)) {
+        if (!preg_match($signed ? '/^-?\d+(?:\.\d+)?$/D' : '/^\d+(?:\.\d+)?$/D', $value)) {
             return null;
         }
         return bcadd($value, '0', 10);

+ 4 - 1
app/Services/ThirdGameCommissionService.php

@@ -195,7 +195,7 @@ class ThirdGameCommissionService
             + self::meta();
     }
 
-    /** One successful quota request per merchant/currency/Beijing day; failures are retried every 30 minutes. */
+    /** Cache current-day rates; retry supplier failures every 30 minutes. */
     public function rates(): array
     {
         $now = now('Asia/Shanghai');
@@ -217,6 +217,9 @@ class ThirdGameCommissionService
                 $retryAt = $now->copy()->addMinutes(self::FAILED_RATES_TTL_MINUTES)->min($now->copy()->endOfDay());
                 Cache::put($key, $unavailable, $retryAt);
                 $result = app(ThirdGameBalanceService::class)->quotaRatios();
+                if (isset($result['retry_seconds'])) {
+                    $retryAt = $now->copy()->addSeconds($result['retry_seconds'])->min($retryAt);
+                }
                 $snapshot = ['date' => $now->toDateString(), 'fetched_at' => now('Asia/Shanghai')->toDateTimeString(),
                     'available' => (bool) $result['ok'], 'ratios' => $result['ratios']];
                 Cache::put($key, $snapshot, $snapshot['available'] ? $now->copy()->endOfDay() : $retryAt);

+ 4 - 0
docs/前端交付/README.md

@@ -2,6 +2,10 @@
 
 本目录是2026-10-26的接口文档交付副本,按**项目 → 前台/后台/代理端**组织。
 
+## 首页第三方游戏商户余额(2026-11-10)
+
+- [后台首页第三方游戏商户余额-前端接入](后台首页第三方游戏商户余额-前端接入.md)
+
 ## 会员绑定上级(2026-11-10)
 
 - [整站与游戏维护子类-总后台前端修改单](整站与游戏维护子类-总后台前端修改单.md):维护可以细到子类(三方平台、F1 彩种、F1 体育项);列表为树形(已包含《维护列表按层级返回》的内容);子类新增「支持内嵌」开关 `embeddable`;新增菜单「F1体育列表」(`GET /admin/config/sportList`,用 `setField` 的 `sport_list` 保存)。

+ 3 - 0
docs/前端交付/bot-28/后台/后台实时推送-前端接入.md

@@ -124,6 +124,7 @@ echo.private('admin.orders.withdraw')
 | `admin.users.online` | `/admin/online/users` | `member_id` | `.user.online` `.user.offline` |
 | `admin.stats.platform_count` | `/admin/online/platformCount` | `scope` | `.stats.updated` |
 | `admin.stats.unprocessed` | `/admin/paymentOrder/unProcessed` | `scope` | `.stats.updated` |
+| `admin.stats.third_game_balance` | `/admin/home/third-game-balance` | `scope` | `.stats.updated` |
 
 Echo 订阅私有频道时自动请求授权接口 `POST /admin/broadcasting/auth`:
 
@@ -301,6 +302,8 @@ Echo 回调收到的对象:
 | `admin.stats.platform_count` | `total`、`apple`、`android`、`pc`、`mobile`、`wechat`、`unknown` |
 | `admin.stats.unprocessed` | `usdtRecharge`、`rgRecharge`、`rmbRecharge`、`usdtWithdraw`、`rgWithdraw`、`rmbWithdraw`、`agentRecharge`、`agentApplication`、`agentPlanApplication`、`shareClaim` |
 
+首页先用 `GET /admin/home/third-game-balance` 获取快照,再订阅 `admin.stats.third_game_balance` 的 `stats.updated`。两个入口返回相同的 `balance`(金额字符串,暂无数据时为 `null`)、`currency`、`stale`(是否为上次有效余额)。余额属于公司商户,不随首页日期筛选变化。WebSocket 不可用时,每 60 秒回读快照。
+
 平台统计的 `wechat` 已包含在 `mobile` 中。订单角标沿用原接口的近 24 小时口径;`agentRecharge`、`agentApplication`(代理申请待审核)、`agentPlanApplication`(合营方案申请待审核)、`shareClaim` 统计全部待处理、不限申请日期。代理三项仅在代理表已迁移时返回。权限分别沿用 `admin/online/platformCount` 和 `admin/paymentOrder/unProcessed`。
 
 relay 默认每 5 秒读取这两项统计,仅在值变化时登记并推送,首次扫描也会发送快照。该机制覆盖跨仓库写入、在线平台变化、活跃及订单时间窗口到期;不依赖 HTTP 列表刷新触发。两频道同样支持 `seq` 去重和断线补发。

+ 23 - 0
docs/前端交付/后台首页第三方游戏商户余额-前端接入.md

@@ -0,0 +1,23 @@
+# 后台首页第三方游戏商户余额
+
+首页新增「第三方游戏商户余额」卡片,显示公司商户当前余额,独立于首页日期筛选。
+
+## 初次读取
+
+沿用后台登录态,进入首页时请求 `GET /admin/home/third-game-balance`,直接读取 `data`。
+
+```json
+{"code":0,"data":{"balance":"1234.5600000000","currency":"CNY","stale":false}}
+```
+
+| 字段 | 展示规则 |
+|---|---|
+| `balance` | 金额字符串。`null` 显示「—」,金额为零时显示 `0` |
+| `currency` | 币种,例如 `CNY` |
+| `stale` | `true` 时保留金额并显示「暂未刷新,显示上次余额」 |
+
+## 实时更新
+
+订阅私有频道 `admin.stats.third_game_balance`,监听 `stats.updated`(直接用 Echo 时为 `.stats.updated`)。用 `event.data` 替换余额数据,字段与 HTTP `data` 相同。
+
+余额或失效状态变化时才推送。需重新同步时调用同一 GET,WebSocket 不可用时每 60 秒回读一次。离开首页时取消订阅和定时器。

+ 3 - 0
docs/总后台/后台实时推送-前端接入.md

@@ -124,6 +124,7 @@ echo.private('admin.orders.withdraw')
 | `admin.users.online` | `/admin/online/users` | `member_id` | `.user.online` `.user.offline` |
 | `admin.stats.platform_count` | `/admin/online/platformCount` | `scope` | `.stats.updated` |
 | `admin.stats.unprocessed` | `/admin/paymentOrder/unProcessed` | `scope` | `.stats.updated` |
+| `admin.stats.third_game_balance` | `/admin/home/third-game-balance` | `scope` | `.stats.updated` |
 
 Echo 订阅私有频道时自动请求授权接口 `POST /admin/broadcasting/auth`:
 
@@ -301,6 +302,8 @@ Echo 回调收到的对象:
 | `admin.stats.platform_count` | `total`、`apple`、`android`、`pc`、`mobile`、`wechat`、`unknown` |
 | `admin.stats.unprocessed` | `usdtRecharge`、`rgRecharge`、`rmbRecharge`、`usdtWithdraw`、`rgWithdraw`、`rmbWithdraw`、`agentRecharge`、`agentApplication`、`agentPlanApplication`、`shareClaim` |
 
+首页先用 `GET /admin/home/third-game-balance` 获取快照,再订阅 `admin.stats.third_game_balance` 的 `stats.updated`。两个入口返回相同的 `balance`(金额字符串,暂无数据时为 `null`)、`currency`、`stale`(是否为上次有效余额)。余额属于公司商户,不随首页日期筛选变化。WebSocket 不可用时,每 60 秒回读快照。
+
 平台统计的 `wechat` 已包含在 `mobile` 中。订单角标沿用原接口的近 24 小时口径;`agentRecharge`、`agentApplication`(代理申请待审核)、`agentPlanApplication`(合营方案申请待审核)、`shareClaim` 统计全部待处理、不限申请日期。代理三项仅在代理表已迁移时返回。权限分别沿用 `admin/online/platformCount` 和 `admin/paymentOrder/unProcessed`。
 
 relay 默认每 5 秒读取这两项统计,仅在值变化时登记并推送,首次扫描也会发送快照。该机制覆盖跨仓库写入、在线平台变化、活跃及订单时间窗口到期;不依赖 HTTP 列表刷新触发。两频道同样支持 `seq` 去重和断线补发。

+ 1 - 0
routes/admin.php

@@ -226,6 +226,7 @@ Route::middleware(['admin.jwt'])->group(function () {
 
         Route::prefix('/home')->group(function () {
             Route::get("/", [Home::class, 'index']);
+            Route::get('/third-game-balance', [Home::class, 'thirdGameBalance'])->defaults('permission_uri', 'admin/home');
         });
 
         Route::prefix("/paymentOrder")->group(function () {

+ 39 - 0
tests/Integration/AdminRealtimeSummaryTest.php

@@ -2,6 +2,7 @@
 
 namespace Tests\Integration;
 
+use App\Http\Controllers\admin\Home;
 use App\Http\Controllers\admin\Online;
 use App\Http\Controllers\admin\PaymentOrder;
 use App\Http\Controllers\admin\User as AdminUser;
@@ -10,6 +11,7 @@ use App\Models\Agent\Agent;
 use App\Services\AdminRealtime\AdminRealtimeFeed;
 use App\Services\AdminRealtime\AdminRealtimeSummaries;
 use App\Services\OnlineUserService;
+use App\Services\ThirdGameBalanceService;
 use App\Services\UserService;
 use Carbon\Carbon;
 use Illuminate\Cache\ArrayStore;
@@ -20,9 +22,11 @@ use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Events\Dispatcher;
 use Illuminate\Foundation\Application;
+use Illuminate\Http\Client\Factory as HttpFactory;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Translation\ArrayLoader;
 use Illuminate\Translation\Translator;
@@ -117,6 +121,41 @@ class AdminRealtimeSummaryTest extends TestCase
         $this->assertSame([], $this->tick(6), '统计值未变,不重复推送');
     }
 
+    public function test_merchant_balance_push_uses_the_same_snapshot_as_the_homepage(): void
+    {
+        config(['third_game.api_url' => 'https://supplier.test', 'third_game.sn' => 'test-merchant',
+            'third_game.key' => 'test-secret', 'third_game.currency' => 'CNY']);
+        $this->app->instance(HttpFactory::class, new HttpFactory());
+        Http::preventStrayRequests();
+        Http::fake(['https://supplier.test/api/server/quota' => Http::sequence()
+            ->push(['code' => 10000, 'data' => ['CNY' => '100.125']])
+            ->push(['code' => 10000, 'data' => ['CNY' => '100.125']])
+            ->push(['code' => 10000, 'data' => ['CNY' => '120']])]);
+        $service = new ThirdGameBalanceService();
+        $channel = 'admin.stats.third_game_balance';
+        $this->assertNull(collect($this->tick(0))->firstWhere('channel', $channel));
+        $initialSeq = AdminRealtimeFeed::lastSeq();
+        $this->assertNull((new Home())->thirdGameBalance($service)->getData(true)['data']['balance']);
+        Http::assertNothingSent();
+
+        $service->refreshMerchantBalance();
+        $changed = collect($this->tick(5))->firstWhere('channel', $channel);
+        $this->assertSame((new Home())->thirdGameBalance($service)->getData(true)['data'], $changed['data']);
+        $this->assertSame('100.1250000000', $changed['data']['balance']);
+
+        Carbon::setTestNow($this->clock->copy()->addSeconds(60));
+        $service->refreshMerchantBalance();
+        $this->assertNull(collect($this->tick(60))->firstWhere('channel', $channel));
+
+        Carbon::setTestNow($this->clock->copy()->addSeconds(120));
+        $service->refreshMerchantBalance();
+        $changed = collect($this->tick(120))->firstWhere('channel', $channel);
+        $this->assertSame('120.0000000000', $changed['data']['balance']);
+        $replay = AdminRealtimeFeed::replay($initialSeq, [$channel]);
+        $this->assertSame($changed['data'], $replay['events'][0]['data']);
+        Http::assertSentCount(3);
+    }
+
     public function test_platform_switch_is_detected_even_when_online_user_ids_do_not_change(): void
     {
         $this->tick(0); $before = OnlineUserService::onlineUserIds();

+ 1 - 1
tests/Integration/AdminRealtimeTest.php

@@ -415,7 +415,7 @@ class AdminRealtimeTest extends TestCase
         DB::table('admin')->insert(['id' => 2, 'username' => 'limited']);
         $this->app['cache']->put('admin_realtime:channels:2', ['admin.users'], 60);
         $limited = \Firebase\JWT\JWT::encode(['iat' => time(), 'exp' => $exp, 'sub' => 2, 'user_id' => 2], str_repeat('k', 32), 'HS256');
-        foreach (['admin.stats.platform_count', 'admin.stats.unprocessed', AdminRealtimeExchangeList::CHANNEL] as $statsChannel) {
+        foreach (['admin.stats.platform_count', 'admin.stats.unprocessed', 'admin.stats.third_game_balance', AdminRealtimeExchangeList::CHANNEL] as $statsChannel) {
             $this->assertSame(200, $call($token, 'private-' . $statsChannel)->getStatusCode());
             $this->assertSame(403, $call($limited, 'private-' . $statsChannel)->getStatusCode(), '统计频道也必须校验管理员权限');
         }

+ 47 - 0
tests/Integration/ThirdGameCommissionRatesTest.php

@@ -2,6 +2,7 @@
 
 namespace Tests\Integration;
 
+use App\Services\ThirdGameBalanceService;
 use App\Services\ThirdGameCommissionService;
 use Carbon\Carbon;
 use Illuminate\Cache\ArrayStore;
@@ -64,6 +65,52 @@ class ThirdGameCommissionRatesTest extends TestCase
         Http::assertSentCount(1);
     }
 
+    public function test_merchant_balance_shares_the_quota_request_and_preserves_the_last_success_on_failure(): void
+    {
+        Http::fake(['https://supplier.test/api/server/quota' => Http::sequence()
+            ->push(['code' => 10000, 'data' => ['CNY' => '100.125', 'ratios' => []]])
+            ->push(['code' => 10009, 'data' => null])
+            ->push(['code' => 10000, 'data' => ['CNY' => '0', 'ratios' => []]])]);
+        $service = new ThirdGameBalanceService();
+        $start = now();
+        $this->assertNull($service->merchantBalance()['balance']);
+        Http::assertNothingSent();
+
+        $service->refreshMerchantBalance();
+        $this->assertTrue($service->quotaRatios()['ok']);
+        $this->assertSame(['balance' => '100.1250000000', 'currency' => 'CNY', 'stale' => false], $service->merchantBalance());
+        Carbon::setTestNow($start->copy()->addSeconds(59));
+        $service->refreshMerchantBalance();
+        Http::assertSentCount(1);
+
+        Carbon::setTestNow($start->copy()->addSeconds(60));
+        $service->refreshMerchantBalance();
+        $service->refreshMerchantBalance();
+        $this->assertSame(['balance' => '100.1250000000', 'currency' => 'CNY', 'stale' => true], $service->merchantBalance());
+        Http::assertSentCount(2);
+
+        Carbon::setTestNow($start->copy()->addSeconds(120));
+        $service->refreshMerchantBalance();
+        $this->assertSame(['balance' => '0.0000000000', 'currency' => 'CNY', 'stale' => false], $service->merchantBalance());
+        Http::assertSentCount(3);
+    }
+
+    public function test_the_shared_quota_cooldown_does_not_reuse_yesterdays_rates(): void
+    {
+        Http::fake(['*' => Http::sequence()
+            ->push(['code' => 10000, 'data' => ['CNY' => '10', 'ratios' => [['platfrom' => 'ag', 'ratio' => '0.1']]]])
+            ->push(['code' => 10000, 'data' => ['CNY' => '10', 'ratios' => [['platfrom' => 'ag', 'ratio' => '0.2']]]])]);
+        Carbon::setTestNow(Carbon::parse('2026-11-04 23:59:50', 'Asia/Shanghai'));
+        (new ThirdGameBalanceService())->refreshMerchantBalance();
+        Carbon::setTestNow(Carbon::parse('2026-11-05 00:00:00', 'Asia/Shanghai'));
+        $service = new ThirdGameCommissionService();
+        $this->assertFalse($service->rates()['available']);
+        Http::assertSentCount(1);
+        Carbon::setTestNow(Carbon::parse('2026-11-05 00:01:00', 'Asia/Shanghai'));
+        $this->assertSame(['ag' => '0.2000000000'], $service->rates()['ratios']);
+        Http::assertSentCount(2);
+    }
+
     public function test_malformed_and_conflicting_rates_are_not_accepted_and_merchant_caches_are_isolated(): void
     {
         Http::fake(['*' => Http::response(['code' => 10000, 'data' => ['ratios' => [