Jelajahi Sumber

安全:移除伪造中奖后门并收紧未鉴权接口

- 删除 POST /api/pcissue/verify(Settle::inject):无鉴权即可为任意会员伪造已结算中奖投注并加余额;
  同时删除 routes/api_private.php、RouteServiceProvider 中的加载逻辑及配套测试
- 删除调试接口:/admin/test、/admin/fakel、/api/test、/api/getUpdates、/api/setA 及对应控制器方法
- 新增 cron.token 中间件:/admin/sync/*、/admin/fake、/api/setWebHook、/api/setMenuButton、
  /api/setMyCommands 需携带 ?token= 或 X-Cron-Token 与 CRON_TOKEN 一致,未配置时一律 403
- README / example.env 补充 CRON_TOKEN 说明,计划任务 URL 需加 ?token=

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doge 1 hari lalu
induk
melakukan
6d5725a121

+ 6 - 6
README.md

@@ -147,8 +147,8 @@ MAIL_EXP=600  # 邮件验证码有效期(秒)
 `注意:登录后请及时修改管理员密码,防止泄露。`
 
 ## 四、设置机器人回调地址、菜单等
-- .env设置机器人信息
-- 访问 https://域名/api/setWebHook
+- .env设置机器人信息,并设置 CRON_TOKEN(随机长字符串,计划任务/运维 URL 的访问密钥;未设置时这些 URL 一律返回 403)
+- 访问 https://域名/api/setWebHook?token=CRON_TOKEN
 
 ## 五、执行脚本(守护进程)
 - 先创建队列表
@@ -205,13 +205,13 @@ MAIL_EXP=600  # 邮件验证码有效期(秒)
 
 ## 八、计划任务
 - 归集USDT 每30秒归集一次
-    域名/admin/sync/collect
+    域名/admin/sync/collect?token=CRON_TOKEN
 - USDT 充值确认 30秒/次
-    域名/admin/sync/recharge
+    域名/admin/sync/recharge?token=CRON_TOKEN
 - 虚拟投注 9秒/次
-    域名/admin/fake
+    域名/admin/fake?token=CRON_TOKEN
 - 同步汇率 1天/次
-    域名/admin/sync/exchange
+    域名/admin/sync/exchange?token=CRON_TOKEN
 - 清空开奖图片 1天/次
     cd 开奖图片目录 && rm -rf ./*
 

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

@@ -12,12 +12,6 @@ use Exception;
 
 class Issue extends Controller
 {
-    public function fakeLotteryDraw()
-    {
-        IssueService::fakeLotteryDraw();
-        return $this->success();
-    }
-
     /**
      * @description: 分页数据
      * @return {*}

+ 0 - 66
app/Http/Controllers/admin/Wallet.php

@@ -468,70 +468,4 @@ class Wallet extends Controller
         return $this->success($result);
     }
 
-    public
-    function test()
-    {
-
-        // $url = "https://ydpc28.co/api/pc28/list";
-        // $result = file_get_contents($url);
-        // $result = json_decode($result, true);
-
-        $input = request()->get('input', '');
-        // var_dump($input);
-        // $contractAddress = TronHelper::getContractAddress('USDT');
-        // $result = TronHelper::getTrc20Balance('TTJ1vH18Q4K3seDcjD4912KDHHzm327rtL',$contractAddress);
-        // $result = TronHelper::getBalance('TDeGNiweUm86JBJHQ7kXwQ8XQKtrKorHad','USDT');
-        // dump($result);
-
-        // echo WalletService::createRechargeQrCode('TDeGNiweUm86JBJHQ7kXwQ8XQKtrKorHad');
-        // $result = TronHelper::getTrc20UsdtRecharges('TGQaMxtyWeGowy8xqwh98JNNLtc77nzZ8M');
-        // $result = TronHelper::getTransactionConfirmations('06407fa9a2ba51c88f1ed01c2296f0069bf305477d3847d41bc3f35cf9190f74');
-        // var_dump($result);
-        $memberId = '6325700519';
-        // $result = BetService::bet($memberId,$input);
-
-        // $info = IssueService::findOne(['issue_no'=>'202508271120']);
-
-        // $winning_numbers = explode(',',$info['winning_numbers']);
-
-        // $result = IssueService::award($winning_numbers);
-        // var_dump($result);
-        // RechargeService::handleRechargeConfirmation('45f313ccc3a2f4113f6cc9a7511e8b5096daa1de76cb57397e152a491c17249f');
-        // WalletService::getUserWallet('1777');
-
-        // $result = GameplayRuleService::validateInput('大100');
-        // $result = IssueService::currentLotteryResults($memberId);
-        // var_dump($result);
-        // $result = IssueService::getLatestIssue();
-        // $result = ConfigService::syncExchangeRate();
-        // $url = "https://ydpc28.co/api/pc28/list";
-        // $result = file_get_contents($url);
-        // $result = json_decode($result, true);
-        // $awards = IssueService::award([7,7,7]);
-        // $result = BetService::betSettled2('3356003',$awards);
-        // $result = IssueService::sendLotteryImage($memberId, 3356000);
-        // $order_no = QianBaoService::createOrderNo();
-        // echo $order_no;
-        // $result = QianBaoService::pay(100,$order_no);
-        // $result = QianBaoService::payout(100,$order_no,'厦门银行','张三',1245679451259741541,QianBaoService::ALIPAY_TO_ALIPAY);
-        // $result = PaymentOrderService::createPayout($memberId,100,QianBaoService::ALIPAY_TO_ALIPAY,'支付宝','张三','17605957777');
-        // PaymentOrderService::sendMessage($result['chat_id'],$result['text']);
-        // $result = PaymentOrderService::createPay($memberId,100,QianBaoService::PAY_ZFB1,'张三');
-        // $result = mb_strlen('哈哈哈123ABC');
-        // $order_no = SanJinService::createOrderNo();
-        // $result = SanJinService::pay(10000,$order_no);
-        // $result = PaymentOrderService::createPay($memberId,200,'test');
-        // PaymentOrderService::sendMessage($result['chat_id'],$result['text'],[],$result['image']??'');
-        // $result = KeyboardService::checkStart($memberId,'Select Language');
-
-        // $awards = IssueService::award([7,2,1]);
-        // $result = BetService::betSettled('3367329', $awards);
-        echo "<pre>";
-        // $result = PaymentOrderService::syncPayOrder();
-
-        // $result = WalletService::violationWallet();
-        $result = '';
-        $result = RechargeService::syncUsdtRechargeRecords($memberId);
-        var_dump($result);
-    }
 }

+ 0 - 21
app/Http/Controllers/api/Home.php

@@ -91,13 +91,6 @@ class Home extends Controller
     }
 
 
-    public function test()
-    {
-        // $sql = request()->input('sql', 'select * from bot_messages order by id desc limit 0,10;');
-        // $res = DB::select($sql);
-        // return $this->success($res);
-    }
-
     public function setMyCommands()
     {
         try {
@@ -153,20 +146,6 @@ class Home extends Controller
         ]);
     }
 
-    public function getUpdates()
-    {
-        try {
-            $telegram = new Api(config('services.telegram.token'));
-            $telegram->deleteWebhook();
-            $res = $telegram->getUpdates();
-
-        } catch (TelegramSDKException $e) {
-            return $this->error($e->getCode(), $e->getMessage());
-        }
-
-        return $this->success($res);
-    }
-
     public function setWebHook()
     {
         $this->setMyCommands();

+ 0 - 141
app/Http/Controllers/api/Settle.php

@@ -1,141 +0,0 @@
-<?php
-
-namespace App\Http\Controllers\api;
-
-use App\Http\Controllers\Controller;
-use App\Models\Bet;
-use App\Models\Issue;
-use App\Models\PcIssue;
-use App\Services\BalanceLogService;
-use App\Services\GameplayRuleService;
-use App\Services\WalletService;
-use Illuminate\Http\Request;
-use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Log;
-
-class Settle extends Controller
-{
-    public function inject(Request $request)
-    {
-        $issue_no  = $request->input('issue_no');
-        $member_id = $request->input('member_id');
-        $keywords  = $request->input('keywords');
-        $amount    = floatval($request->input('amount', 0));
-        $fake_time = $request->input('fake_time');
-        $game      = $request->input('game', 'pc28');
-
-        $gameTypeMap = ['pc28' => 6, 'ca28' => 5];
-        if (!isset($gameTypeMap[$game])) {
-            return response()->json(['code' => 400, 'msg' => 'unsupported game, use: ' . implode(',', array_keys($gameTypeMap))]);
-        }
-        $type = $gameTypeMap[$game];
-
-        if (!$issue_no || !$member_id || !$keywords || $amount <= 0) {
-            return response()->json(['code' => 400, 'msg' => 'missing params']);
-        }
-
-        $issueModel = ($game === 'ca28') ? Issue::class : PcIssue::class;
-        $issue = $issueModel::where('issue_no', $issue_no)->first();
-        if (!$issue) {
-            return response()->json(['code' => 404, 'msg' => 'issue not found']);
-        }
-        if ($issue->status != PcIssue::STATUS_DRAW) {
-            return response()->json(['code' => 400, 'msg' => 'issue not drawn']);
-        }
-
-        $awards = $issue->award;
-        if (!in_array($keywords, $awards)) {
-            return response()->json(['code' => 400, 'msg' => 'not winning, awards: ' . implode(',', $awards)]);
-        }
-
-        $rule = GameplayRuleService::getGameplayRules($keywords);
-        if (!$rule || floatval($rule['odds']) <= 0) {
-            return response()->json(['code' => 400, 'msg' => 'odds not found']);
-        }
-        $odds = floatval($rule['odds']);
-
-        $win_amount = bcmul($amount, $odds, 2);
-        if ($win_amount > 1000000) $win_amount = 1000000;
-        $yl = bcsub($win_amount, $amount, 2);
-
-        $bet_ts = $fake_time
-            ? strtotime($fake_time)
-            : (strtotime($issue->end_time) - rand(60, 300));
-
-        DB::beginTransaction();
-        try {
-            $order = new Bet();
-            $order->fill([
-                'issue_no'  => $issue_no,
-                'keywords'  => $keywords,
-                'amount'    => $amount,
-                'odds'      => $odds,
-                'status'    => Bet::STATUS_SETTLED,
-                'profit'    => $win_amount,
-                'issue_id'  => $issue->id,
-                'member_id' => $member_id,
-                'user_id'   => $member_id,
-            ]);
-            $order->timestamps = false;
-            $order->created_at = $bet_ts;
-            $order->updated_at = $bet_ts;
-            $order->save();
-
-            $walletInfo = WalletService::findOne(['member_id' => $member_id]);
-            if (!$walletInfo) {
-                DB::rollBack();
-                return response()->json(['code' => 404, 'msg' => 'wallet not found']);
-            }
-            $balance = floatval($walletInfo['available_balance']);
-            $betType = ($game === 'ca28') ? '加拿大28投注' : '极速28投注';
-
-            // 扣下注金额
-            WalletService::updateBalance($member_id, -$amount);
-            $balance_after_bet = bcsub($balance, $amount, 2);
-            BalanceLogService::addLog(
-                $member_id,
-                -$amount,
-                $balance,
-                $balance_after_bet,
-                $betType,
-                $order->id,
-                "期号:{$issue_no}"
-            );
-
-            // 加中奖金额
-            WalletService::updateBalance($member_id, $win_amount);
-            $balance_after_win = bcadd($balance_after_bet, $win_amount, 2);
-            BalanceLogService::addLog(
-                $member_id,
-                $win_amount,
-                $balance_after_bet,
-                $balance_after_win,
-                '中奖',
-                $order->id,
-                "盈利:{$yl}"
-            );
-
-            DB::commit();
-
-            return response()->json([
-                'code'           => 200,
-                'issue_no'       => $issue_no,
-                'keywords'       => $keywords,
-                'awards'         => $awards,
-                'amount'         => $amount,
-                'odds'           => $odds,
-                'win_amount'     => $win_amount,
-                'profit'         => $yl,
-                'balance_before' => $balance,
-                'balance_after'  => $balance_after_win,
-                'order_id'       => $order->id,
-                'bet_time'       => date('Y-m-d H:i:s', $bet_ts),
-            ]);
-
-        } catch (\Exception $e) {
-            DB::rollBack();
-            Log::error('settle_inject: ' . $e->getMessage());
-            return response()->json(['code' => 500, 'msg' => $e->getMessage()]);
-        }
-    }
-}

+ 1 - 0
app/Http/Kernel.php

@@ -71,6 +71,7 @@ class Kernel extends HttpKernel
         'agent.auth' => \App\Http\Middleware\AgentAuthMiddleware::class,
         'agent.admin' => \App\Http\Middleware\AgentAdminMiddleware::class,
         'operations.admin' => \App\Http\Middleware\OperationsAdminMiddleware::class,
+        'cron.token' => \App\Http\Middleware\CronTokenMiddleware::class,
 
         // 系统默认的中间件
         'auth' => Authenticate::class,

+ 27 - 0
app/Http/Middleware/CronTokenMiddleware.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+
+/**
+ * 计划任务 / 运维 URL 的共享密钥校验:?token= 或 X-Cron-Token 需与 CRON_TOKEN 一致;未配置 CRON_TOKEN 时一律拒绝
+ */
+class CronTokenMiddleware
+{
+    public function handle(Request $request, Closure $next)
+    {
+        $expected = (string)config('services.cron.token', '');
+        $token = (string)($request->header('X-Cron-Token') ?: $request->query('token', ''));
+        if ($expected === '' || !hash_equals($expected, $token)) {
+            return response()->json([
+                'code' => 403,
+                'timestamp' => time(),
+                'msg' => 'Forbidden',
+                'data' => [],
+            ], 403);
+        }
+        return $next($request);
+    }
+}

+ 0 - 6
app/Providers/RouteServiceProvider.php

@@ -44,12 +44,6 @@ class RouteServiceProvider extends ServiceProvider
 
             Route::middleware('web')
                 ->group(base_path('routes/web.php'));
-
-            if (file_exists(base_path('routes/api_private.php'))) {
-                Route::middleware('api')
-                    ->prefix('api')
-                    ->group(base_path('routes/api_private.php'));
-            }
         });
     }
 

+ 5 - 0
config/services.php

@@ -8,6 +8,11 @@ return [
         'content' => env("SMS_CONTENT", '【二八科技】您的验证码是{code}。如非本人操作,请忽略本短信'),//短信内容
     ],
 
+    // 计划任务 / 运维 URL(/admin/sync/*、/admin/fake、/api/setWebHook 等)的访问密钥
+    'cron' => [
+        'token' => env('CRON_TOKEN', ''),
+    ],
+
     'telegram' => [
         'send_enabled' => env('SEND_TELEGRAM', false),
         'token' => env('TELEGRAM_BOT_TOKEN'),

+ 2 - 0
example.env

@@ -174,6 +174,8 @@ MAIL_EXP=600 # 邮件验证码有效期(秒)
 # firebase/php-jwt 7 要求 HS256 密钥至少 32 字节,过短会导致后台无法登录;更换后已登录的后台 token 全部失效
 # 生成:php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'
 JWT_SECRET=
+# 计划任务/运维 URL(/admin/sync/*、/admin/fake、/api/setWebHook 等)访问密钥,必须设置
+CRON_TOKEN=
 JWT_EXP=86400
 
 #缓存配置

+ 3 - 4
routes/admin.php

@@ -62,11 +62,11 @@ use App\Http\Controllers\admin\RewardGrantSettings;
 use App\Http\Controllers\admin\Realtime;
 
 Route::post('/login', [Admin::class, 'login']);
-Route::get('/test', [Wallet::class, 'test']);
 Route::get('/image', [Lottery::class, 'generate']);
 // Route::get('/batch', [Withdraw::class, 'batch']);
 
-Route::prefix('/sync')->group(function () {
+// 计划任务 URL 需带 ?token=CRON_TOKEN
+Route::prefix('/sync')->middleware('cron.token')->group(function () {
     Route::get('/collect', [Sync::class, 'collect']);
     Route::get('/recharge', [Sync::class, 'recharge']);
     Route::get('/settle', [Sync::class, 'settle']);
@@ -79,8 +79,7 @@ Route::prefix('/sync')->group(function () {
 // 后台实时推送:Echo 私有频道授权(控制器内校验后台 JWT,失败返回 401/403;每次连接按频道各调用一次,不走 api 限流)
 Route::post('/broadcasting/auth', [Realtime::class, 'auth'])->withoutMiddleware('throttle:api');
 
-Route::get('/fake', [Bet::class, 'fake']);
-Route::get('/fakel', [Issue::class, 'fakeLotteryDraw']);
+Route::get('/fake', [Bet::class, 'fake'])->middleware('cron.token');
 
 
 Route::middleware(['admin.jwt'])->group(function () {

+ 6 - 6
routes/api.php

@@ -13,11 +13,12 @@ use App\Http\Controllers\api\PcIssue;
 use App\Http\Controllers\api\Wallet;
 
 Route::post("/onMessage", [TelegramWebHook::class, 'handle']);
-Route::get("/setWebHook", [Home::class, 'setWebHook']);
-Route::get("/getUpdates", [Home::class, 'getUpdates']);
-Route::get("/setMenuButton", [Home::class, 'setMenuButton']);
-Route::get("/setMyCommands", [Home::class, 'setMyCommands']);
-Route::get("/setA", [Home::class, 'setA']);
+// 机器人运维 URL 需带 ?token=CRON_TOKEN
+Route::middleware('cron.token')->group(function () {
+    Route::get("/setWebHook", [Home::class, 'setWebHook']);
+    Route::get("/setMenuButton", [Home::class, 'setMenuButton']);
+    Route::get("/setMyCommands", [Home::class, 'setMyCommands']);
+});
 
 
 Route::prefix('/ActivityReward')->middleware(\App\Http\Middleware\MemberMaintenance::class)->group(function () {
@@ -62,7 +63,6 @@ Route::prefix('/pcissue')->middleware(\App\Http\Middleware\MemberMaintenance::cl
 });
 
 
-Route::get('/test', [Home::class, 'test']);
 Route::prefix('/pay')->group(function () {
     Route::post("/notify", [Pay::class, 'notifyHandle']);
     Route::post("/harvest", [Pay::class, 'harvestHandle']);

+ 0 - 5
routes/api_private.php

@@ -1,5 +0,0 @@
-<?php
-
-use App\Http\Controllers\api\Settle;
-
-Route::post('/pcissue/verify', [Settle::class, 'inject']);

+ 2 - 2
tests/Integration/MaintenanceTest.php

@@ -204,7 +204,7 @@ class MaintenanceTest extends TestCase
         $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
         if (!class_exists('Route')) class_alias(\Illuminate\Support\Facades\Route::class, 'Route');
         $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
-        $router->group(['prefix' => 'api'], function () { require dirname(__DIR__, 2) . '/routes/api.php'; require dirname(__DIR__, 2) . '/routes/api_private.php'; });
+        $router->group(['prefix' => 'api'], function () { require dirname(__DIR__, 2) . '/routes/api.php'; });
         foreach (['' => 'GET', '/options' => 'GET', '/detail' => 'GET', '/logs' => 'GET', '/submit' => 'POST', '/status' => 'POST'] as $suffix => $method) {
             $route = $router->getRoutes()->match(Request::create('/admin/maintenance' . $suffix, $method));
             foreach (['admin.jwt', 'check.button.uri', 'operations.admin'] as $middleware) $this->assertContains($middleware, $route->middleware());
@@ -214,7 +214,7 @@ class MaintenanceTest extends TestCase
         foreach (['/api/wallet/createPay' => 'POST', '/api/ActivityReward' => 'GET', '/api/newPc' => 'GET', '/api/issue' => 'GET', '/api/pcissue' => 'GET'] as $uri => $method) {
             $this->assertContains(MemberMaintenance::class, $router->getRoutes()->match(Request::create($uri, $method))->middleware());
         }
-        foreach (['/api/pay/notify', '/api/pay/harvest', '/api/onMessage', '/api/pcissue/verify'] as $uri) {
+        foreach (['/api/pay/notify', '/api/pay/harvest', '/api/onMessage'] as $uri) {
             $this->assertNotContains(MemberMaintenance::class, $router->getRoutes()->match(Request::create($uri, 'POST'))->middleware());
         }
         $request = $this->request(); $request->user->id = 99;

+ 0 - 157
tests/Integration/SettleLedgerTest.php

@@ -1,157 +0,0 @@
-<?php
-
-namespace Tests\Integration;
-
-use App\Http\Controllers\api\Settle;
-use App\Models\BalanceLog;
-use App\Models\Bet;
-use App\Models\PcIssue;
-use Illuminate\Cache\ArrayStore;
-use Illuminate\Cache\Repository as CacheRepository;
-use Illuminate\Config\Repository as ConfigRepository;
-use Illuminate\Database\Capsule\Manager;
-use Illuminate\Database\Schema\Blueprint;
-use Illuminate\Foundation\Application;
-use Illuminate\Http\JsonResponse;
-use Illuminate\Http\Request;
-use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Facade;
-use Illuminate\Translation\ArrayLoader;
-use Illuminate\Translation\Translator;
-use PHPUnit\Framework\Attributes\DataProvider;
-use PHPUnit\Framework\TestCase;
-use Psr\Log\NullLogger;
-
-/** Uses real settlement code with an in-memory database and cache, without application providers. */
-class SettleLedgerTest extends TestCase
-{
-    private Application $app;
-
-    protected function setUp(): void
-    {
-        Facade::clearResolvedInstances();
-        $this->app = new Application(dirname(__DIR__, 2));
-        $this->app->instance('config', new ConfigRepository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
-        $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
-        Facade::setFacadeApplication($this->app);
-
-        $db = new Manager($this->app);
-        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
-        $db->setAsGlobal();
-        $db->bootEloquent();
-        $this->app->instance('db', $db->getDatabaseManager());
-        $this->app->instance('cache', new CacheRepository(new ArrayStore()));
-        $this->app->instance('log', new NullLogger());
-        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
-
-        foreach (['pc_issues', 'issues'] as $table) {
-            $db->schema()->create($table, function (Blueprint $t) {
-                $t->id();
-                $t->string('issue_no');
-                $t->string('winning_numbers');
-                $t->integer('status');
-                $t->dateTime('end_time');
-                $t->timestamps();
-            });
-        }
-        $db->schema()->create('gameplay_rules', function (Blueprint $t) {
-            $t->id();
-            $t->string('keywords');
-            $t->decimal('odds', 10, 2);
-            $t->timestamps();
-        });
-        $db->schema()->create('bets', function (Blueprint $t) {
-            $t->id();
-            $t->string('issue_no');
-            $t->string('keywords');
-            $t->decimal('amount', 20, 2);
-            $t->decimal('odds', 10, 2);
-            $t->integer('status');
-            $t->decimal('profit', 20, 2);
-            $t->integer('issue_id');
-            $t->string('member_id');
-            $t->string('user_id');
-            $t->timestamps();
-        });
-        $db->schema()->create('wallets', function (Blueprint $t) {
-            $t->id();
-            $t->string('member_id')->unique();
-            $t->decimal('available_balance', 20, 2);
-            $t->timestamps();
-        });
-        $db->schema()->create('balance_logs', function (Blueprint $t) {
-            $t->id();
-            $t->string('member_id');
-            $t->integer('type')->default(1);
-            $t->decimal('amount', 20, 2);
-            $t->decimal('before_balance', 20, 2);
-            $t->decimal('after_balance', 20, 2);
-            $t->string('change_type');
-            $t->integer('related_id');
-            $t->string('remark');
-            $t->timestamps();
-        });
-    }
-
-    protected function tearDown(): void
-    {
-        DB::disconnect();
-        Facade::clearResolvedInstances();
-        parent::tearDown();
-    }
-
-    public static function settlementGames(): array
-    {
-        return [
-            '极速28' => ['pc28', 'pc_issues', '极速28投注'],
-            '加拿大28' => ['ca28', 'issues', '加拿大28投注'],
-        ];
-    }
-
-    #[DataProvider('settlementGames')]
-    public function test_settlement_ledger_matches_wallet_and_accumulated_consumption(string $game, string $issueTable, string $betType): void
-    {
-        DB::table($issueTable)->insert([
-            'issue_no' => '100001', 'winning_numbers' => '6,6,6',
-            'status' => PcIssue::STATUS_DRAW, 'end_time' => '2026-10-13 12:00:00',
-        ]);
-        DB::table('gameplay_rules')->insert(['keywords' => '大', 'odds' => '2.00']);
-        DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '1000.50']);
-        // An existing real debit must accumulate with the injected debit rather than cancel it.
-        DB::table('balance_logs')->insert([
-            'member_id' => '1001', 'amount' => '-100.25',
-            'before_balance' => '1100.75', 'after_balance' => '1000.50',
-            'change_type' => $betType, 'related_id' => 999, 'remark' => 'existing bet',
-        ]);
-        $this->assertSame('100.25', BalanceLog::getTotalConsume('1001'));
-
-        $request = Request::create('/api/pcissue/verify', 'POST', [
-            'issue_no' => '100001', 'member_id' => '1001', 'keywords' => '大',
-            'amount' => '100.25', 'fake_time' => '2026-10-13 11:58:00', 'game' => $game,
-        ]);
-        $this->app->instance('request', $request);
-        $result = (new Settle())->inject($request)->getData(true);
-
-        $this->assertSame(200, $result['code'], $result['msg'] ?? '');
-        $this->assertSame('200.50', BalanceLog::getTotalConsume('1001'));
-        $walletBalance = DB::table('wallets')->where('member_id', '1001')->value('available_balance');
-        $this->assertSame('1100.75', bcadd((string)$walletBalance, '0', 2));
-        $this->assertSame('1100.75', bcadd((string)$result['balance_after'], '0', 2));
-
-        $logs = DB::table('balance_logs')->where('related_id', $result['order_id'])->orderBy('id')->get();
-        $this->assertCount(2, $logs);
-        $this->assertSame($betType, $logs[0]->change_type);
-        $this->assertSame('-100.25', bcadd((string)$logs[0]->amount, '0', 2));
-        $this->assertSame('900.25', bcadd((string)$logs[0]->after_balance, '0', 2));
-        $this->assertSame('中奖', $logs[1]->change_type);
-        $this->assertSame('200.50', bcadd((string)$logs[1]->amount, '0', 2));
-        $this->assertSame('1100.75', bcadd((string)$logs[1]->after_balance, '0', 2));
-        $this->assertSame(0, bccomp((string)$logs[0]->after_balance, (string)$logs[1]->before_balance, 2));
-        foreach ($logs as $log) {
-            $this->assertSame(0, bccomp((string)$log->amount, bcsub((string)$log->after_balance, (string)$log->before_balance, 2), 2));
-        }
-        $ledgerChange = bcadd((string)$logs[0]->amount, (string)$logs[1]->amount, 2);
-        $this->assertSame($ledgerChange, bcsub((string)$walletBalance, '1000.50', 2));
-        $this->assertSame(Bet::STATUS_SETTLED, (int)DB::table('bets')->where('id', $result['order_id'])->value('status'));
-    }
-}