doge 16 시간 전
부모
커밋
4911b85a40
52개의 변경된 파일과 2151개의 추가작업 그리고 55개의 파일을 삭제
  1. 32 0
      app/Console/Commands/SimulationRun.php
  2. 23 0
      app/Console/Commands/SyncUserType.php
  3. 3 0
      app/Console/Kernel.php
  4. 1 0
      app/Http/Controllers/admin/Balance.php
  5. 1 0
      app/Http/Controllers/admin/Bet.php
  6. 15 12
      app/Http/Controllers/admin/Home.php
  7. 2 1
      app/Http/Controllers/admin/JisuGameOrder.php
  8. 2 1
      app/Http/Controllers/admin/LhcOrder.php
  9. 2 2
      app/Http/Controllers/admin/Operation.php
  10. 1 0
      app/Http/Controllers/admin/Order.php
  11. 8 6
      app/Http/Controllers/admin/PaymentOrder.php
  12. 427 0
      app/Http/Controllers/admin/Simulation.php
  13. 2 1
      app/Http/Controllers/admin/SportGameOrder.php
  14. 2 0
      app/Http/Controllers/admin/User.php
  15. 3 3
      app/Http/Controllers/admin/Wallet.php
  16. 2 0
      app/Http/Controllers/admin/Withdraw.php
  17. 1 0
      app/Http/Controllers/admin/YueBao.php
  18. 1 0
      app/Models/LhcOrder.php
  19. 5 0
      app/Models/User.php
  20. 1 0
      app/Services/BackflowService.php
  21. 1 1
      app/Services/BalanceLogService.php
  22. 14 5
      app/Services/BetService.php
  23. 1 0
      app/Services/ManualAuditService.php
  24. 3 3
      app/Services/MemberDailyReportService.php
  25. 11 2
      app/Services/PaymentOrderService.php
  26. 1 1
      app/Services/RechargeService.php
  27. 1 0
      app/Services/Reporting/ReportAdmin.php
  28. 1 1
      app/Services/Reporting/ReportContext.php
  29. 2 0
      app/Services/Reporting/ReportReader.php
  30. 2 1
      app/Services/Reporting/ReportSync.php
  31. 1 0
      app/Services/RewardBaseSyncService.php
  32. 3 1
      app/Services/Sharing/BetFactSynchronizer.php
  33. 2 2
      app/Services/Sharing/ShareSettlementService.php
  34. 168 0
      app/Services/Simulation/SimulationBetService.php
  35. 143 0
      app/Services/Simulation/SimulationFundService.php
  36. 117 0
      app/Services/Simulation/SimulationMemberService.php
  37. 86 0
      app/Services/Simulation/SimulationResetService.php
  38. 146 0
      app/Services/Simulation/SimulationRunner.php
  39. 82 0
      app/Services/Simulation/SimulationSettings.php
  40. 116 0
      app/Services/Simulation/SimulationTaskService.php
  41. 1 0
      app/Services/ThirdGameOrderService.php
  42. 2 2
      app/Services/ThirdPartyDepositService.php
  43. 1 0
      app/Services/UserService.php
  44. 1 0
      app/Services/WalletFreezeService.php
  45. 2 1
      app/Services/WithdrawService.php
  46. 99 0
      app/Support/MemberAudience.php
  47. 34 9
      app/Support/StatsScope.php
  48. 38 0
      database/migrations/2026_11_11_120000_add_user_type_to_users.php
  49. 97 0
      database/migrations/2026_11_11_130000_create_simulation_tables.php
  50. 66 0
      database/migrations/2026_11_11_140000_add_simulation_admin_menus.php
  51. 51 0
      routes/admin.php
  52. 324 0
      tests/Integration/SimulationTest.php

+ 32 - 0
app/Console/Commands/SimulationRun.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\Simulation\SimulationRunner;
+use App\Services\Simulation\SimulationSettings;
+use App\Support\MemberAudience;
+use App\Support\SyncCommandLock;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Schema;
+
+class SimulationRun extends Command
+{
+    protected $signature = 'simulation:run {--once : 只推进一次(调试用)}';
+
+    protected $description = '模拟管理:推进自动任务。每分钟由调度启动,进程内每 2 秒推进一次,约 55 秒后退出';
+
+    public function handle(SyncCommandLock $lock): int
+    {
+        if (!MemberAudience::enabled() || !Schema::hasTable('simulation_task_runs') || !SimulationSettings::enabled()) return self::SUCCESS;
+        $lock->run('simulation:run', function () {
+            $deadline = microtime(true) + 55;
+            do {
+                $done = SimulationRunner::tick();
+                if ($this->option('once')) { $this->info("执行了 {$done} 个动作"); break; }
+                sleep(2);
+            } while (microtime(true) < $deadline);
+            return 0;
+        });
+        return self::SUCCESS;
+    }
+}

+ 23 - 0
app/Console/Commands/SyncUserType.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Support\MemberAudience;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+
+class SyncUserType extends Command
+{
+    protected $signature = 'users:sync-type';
+
+    protected $description = '给前台新注册的游客(from = 2)补上 user_type = 1;游客转正后改回 0。后台模拟会员(2)不动';
+
+    public function handle(): int
+    {
+        if (!MemberAudience::enabled()) return self::SUCCESS;
+        $guests = DB::table('users')->where('from', '2')->where('user_type', 0)->update(['user_type' => 1]);
+        $normal = DB::table('users')->where('user_type', 1)->where(fn ($q) => $q->whereNull('from')->orWhere('from', '<>', '2'))->update(['user_type' => 0]);
+        if ($guests || $normal) $this->info("游客 +{$guests},转正 {$normal}");
+        return self::SUCCESS;
+    }
+}

+ 3 - 0
app/Console/Kernel.php

@@ -50,6 +50,9 @@ class Kernel extends ConsoleKernel
         $schedule->command('share:settle --reconcile')->everyFiveMinutes()->withoutOverlapping(120);
         $schedule->command('share:settle --reconcile')->everyFiveMinutes()->withoutOverlapping(120);
         $schedule->command('share:settle')->timezone('Asia/Shanghai')->dailyAt('00:10')->withoutOverlapping(180);
         $schedule->command('share:settle')->timezone('Asia/Shanghai')->dailyAt('00:10')->withoutOverlapping(180);
         $schedule->command('payment:pay-timeout')->everyMinute()->withoutOverlapping(10);
         $schedule->command('payment:pay-timeout')->everyMinute()->withoutOverlapping(10);
+        // 模拟管理:推进自动任务(总开关关闭时立即退出);给 melbet 新建的游客补 user_type 标签
+        $schedule->command('simulation:run')->everyMinute()->withoutOverlapping(5)->runInBackground();
+        $schedule->command('users:sync-type')->everyMinute()->withoutOverlapping(5);
         $schedule->command('admin-realtime:prune')->hourly()->withoutOverlapping(30);
         $schedule->command('admin-realtime:prune')->hourly()->withoutOverlapping(30);
     }
     }
 
 

+ 1 - 0
app/Http/Controllers/admin/Balance.php

@@ -45,6 +45,7 @@ class Balance extends Controller
             }
             }
             $query = BalanceLog::join('users', 'users.member_id', '=', 'balance_logs.member_id')
             $query = BalanceLog::join('users', 'users.member_id', '=', 'balance_logs.member_id')
                         ->where(BalanceLogService::getWhere($params));
                         ->where(BalanceLogService::getWhere($params));
+            \App\Support\MemberAudience::visibleUsers($query, 'users'); // 后台模拟会员只在模拟管理里出现
             if (!empty($params['is_fancai'])) {
             if (!empty($params['is_fancai'])) {
                 $bonusLogs = new MemberBonusLogService();
                 $bonusLogs = new MemberBonusLogService();
                 $data['change_types'] = MemberBonusLogService::types();
                 $data['change_types'] = MemberBonusLogService::types();

+ 1 - 0
app/Http/Controllers/admin/Bet.php

@@ -35,6 +35,7 @@ class Bet extends Controller
             $limit = request()->input('limit', 10);
             $limit = request()->input('limit', 10);
 
 
             $query = BetModel::where(BetService::getWhere($params));
             $query = BetModel::where(BetService::getWhere($params));
+            \App\Support\MemberAudience::visibleMembers($query, 'member_id'); // 后台模拟会员只在模拟管理里出现
 
 
 
 
             if (isset($params['username']) && !empty($params['username'])) {
             if (isset($params['username']) && !empty($params['username'])) {

+ 15 - 12
app/Http/Controllers/admin/Home.php

@@ -41,8 +41,8 @@ class Home extends Controller
 
 
             //投注统计
             //投注统计
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
-            $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%投注')->where('users.from', '<>', 2);
-            StatsScope::excludeTestUsers($query, 'users'); // 测试代理名下会员不统计
+            $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%投注');
+            StatsScope::realUsers($query, 'users'); // 游客、模拟会员、测试代理名下会员不统计
             $query1 = clone $query;
             $query1 = clone $query;
             $bet['amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注金额
             $bet['amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注金额
             $bet['amount'] = abs($bet['amount']);
             $bet['amount'] = abs($bet['amount']);
@@ -52,7 +52,8 @@ class Home extends Controller
 
 
             //投注统计
             //投注统计
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
-            $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%投注')->where('users.from', 2);
+            $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%投注');
+            StatsScope::guestUsers($query, 'users'); // 游客(不含后台模拟会员)
             $query1 = clone $query;
             $query1 = clone $query;
             $virtual_bet['amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注金额
             $virtual_bet['amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注金额
             $virtual_bet['amount'] = abs($virtual_bet['amount']);
             $virtual_bet['amount'] = abs($virtual_bet['amount']);
@@ -62,8 +63,8 @@ class Home extends Controller
 
 
             //投注退款统计
             //投注退款统计
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
-            $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%退款')->where('users.from', '<>', 2);
-            StatsScope::excludeTestUsers($query, 'users'); // 测试代理名下会员不统计
+            $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%退款');
+            StatsScope::realUsers($query, 'users'); // 游客、模拟会员、测试代理名下会员不统计
             $query1 = clone $query;
             $query1 = clone $query;
             $bet['refund_amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注退款金额
             $bet['refund_amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注退款金额
             $query1 = clone $query;
             $query1 = clone $query;
@@ -71,7 +72,8 @@ class Home extends Controller
 
 
             //投注退款统计
             //投注退款统计
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
-            $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%退款')->where('users.from', 2);
+            $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%退款');
+            StatsScope::guestUsers($query, 'users'); // 游客(不含后台模拟会员)
             $query1 = clone $query;
             $query1 = clone $query;
             $virtual_bet['refund_amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注退款金额
             $virtual_bet['refund_amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注退款金额
             $query1 = clone $query;
             $query1 = clone $query;
@@ -79,8 +81,8 @@ class Home extends Controller
 
 
             //投注中奖统计
             //投注中奖统计
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
-            $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%中奖')->where('users.from', '<>', 2);
-            StatsScope::excludeTestUsers($query, 'users'); // 测试代理名下会员不统计
+            $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%中奖');
+            StatsScope::realUsers($query, 'users'); // 游客、模拟会员、测试代理名下会员不统计
             $query1 = clone $query;
             $query1 = clone $query;
             $bet['win_amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注中奖金额
             $bet['win_amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注中奖金额
             $query1 = clone $query;
             $query1 = clone $query;
@@ -88,7 +90,8 @@ class Home extends Controller
 
 
             //投注中奖统计
             //投注中奖统计
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
-            $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%中奖')->where('users.from', 2);
+            $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%中奖');
+            StatsScope::guestUsers($query, 'users'); // 游客(不含后台模拟会员)
             $query1 = clone $query;
             $query1 = clone $query;
             $virtual_bet['win_amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注中奖金额
             $virtual_bet['win_amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注中奖金额
             $query1 = clone $query;
             $query1 = clone $query;
@@ -128,12 +131,12 @@ class Home extends Controller
             $activity['today_unfreeze_amount'] = abs($activity['today_unfreeze_amount']);
             $activity['today_unfreeze_amount'] = abs($activity['today_unfreeze_amount']);
 
 
             //新增会员统计
             //新增会员统计
-            $query = StatsScope::excludeTestUsers(User::where('from','<>',2), 'users');
+            $query = StatsScope::realUsers(User::query(), 'users');
             $totalUser = (float)$query->whereBetween('created_at', [$start, $end])->count('id');
             $totalUser = (float)$query->whereBetween('created_at', [$start, $end])->count('id');
             $todayUser = (float)$query->whereBetween('created_at', [$today, $end])->count('id');//今日新增会员
             $todayUser = (float)$query->whereBetween('created_at', [$today, $end])->count('id');//今日新增会员
 
 
             //新增会员统计
             //新增会员统计
-            $query = User::where('from',2);
+            $query = StatsScope::guestUsers(User::query(), 'users');
             $totalVirtualUser = (float)$query->whereBetween('created_at', [$start, $end])->count('id');
             $totalVirtualUser = (float)$query->whereBetween('created_at', [$start, $end])->count('id');
             $todayVirtualUser = (float)$query->whereBetween('created_at', [$today, $end])->count('id');//今日新增会员
             $todayVirtualUser = (float)$query->whereBetween('created_at', [$today, $end])->count('id');//今日新增会员
             
             
@@ -308,4 +311,4 @@ class Home extends Controller
         }
         }
         return $this->success($result);
         return $this->success($result);
     }
     }
-}
+}

+ 2 - 1
app/Http/Controllers/admin/JisuGameOrder.php

@@ -44,6 +44,7 @@ class JisuGameOrder extends Controller
                             $join->on('jisu_game_order.type', '=', 'jisu_lottery.type')
                             $join->on('jisu_game_order.type', '=', 'jisu_lottery.type')
                                  ->on('jisu_game_order.issue', '=', 'jisu_lottery.issue');
                                  ->on('jisu_game_order.issue', '=', 'jisu_lottery.issue');
                         });
                         });
+            \App\Support\MemberAudience::visibleUsers($query, 'users'); // 后台模拟会员只在模拟管理里出现
             if (!empty($params['id'])) {
             if (!empty($params['id'])) {
                 $query = $query->where('jisu_game_order.id', $params['id']);
                 $query = $query->where('jisu_game_order.id', $params['id']);
             }
             }
@@ -93,7 +94,7 @@ class JisuGameOrder extends Controller
                 'id' => ['required', 'integer'],
                 'id' => ['required', 'integer'],
             ]);
             ]);
             $id = request()->input('id');
             $id = request()->input('id');
-            $order = JisuGameOrderModel::join('users', 'users.member_id', '=', 'jisu_game_order.member_id')
+            $order = \App\Support\MemberAudience::visibleUsers(JisuGameOrderModel::join('users', 'users.member_id', '=', 'jisu_game_order.member_id'), 'users')
                         ->join('jisu_gameplay', 'jisu_gameplay.id', '=', 'jisu_game_order.gameplay_id')
                         ->join('jisu_gameplay', 'jisu_gameplay.id', '=', 'jisu_game_order.gameplay_id')
                         ->leftJoin('jisu_lottery', function($join) {
                         ->leftJoin('jisu_lottery', function($join) {
                             $join->on('jisu_game_order.type', '=', 'jisu_lottery.type')
                             $join->on('jisu_game_order.type', '=', 'jisu_lottery.type')

+ 2 - 1
app/Http/Controllers/admin/LhcOrder.php

@@ -51,6 +51,7 @@ class LhcOrder extends Controller
             $query = new LhcOrderModel();
             $query = new LhcOrderModel();
             
             
             $query = LhcOrderModel::with(['items'])->join('users', 'users.member_id', '=', 'lhc_order.member_id');
             $query = LhcOrderModel::with(['items'])->join('users', 'users.member_id', '=', 'lhc_order.member_id');
+            \App\Support\MemberAudience::visibleUsers($query, 'users'); // 后台模拟会员只在模拟管理里出现
             if (!empty($params['id'])) {
             if (!empty($params['id'])) {
                 $query = $query->where('lhc_order.id', $params['id']);
                 $query = $query->where('lhc_order.id', $params['id']);
             }
             }
@@ -165,7 +166,7 @@ class LhcOrder extends Controller
                 'id' => ['required', 'integer'],
                 'id' => ['required', 'integer'],
             ]);
             ]);
             $id = request()->input('id');
             $id = request()->input('id');
-            $info = LhcOrderModel::with(['items'])
+            $info = \App\Support\MemberAudience::visibleMembers(LhcOrderModel::with(['items']), 'lhc_order.member_id')
                 ->leftJoin('users', 'users.member_id', '=', 'lhc_order.member_id')
                 ->leftJoin('users', 'users.member_id', '=', 'lhc_order.member_id')
                 ->where('lhc_order.id', $id)
                 ->where('lhc_order.id', $id)
                 ->select('lhc_order.*', 'users.first_name')
                 ->select('lhc_order.*', 'users.first_name')

+ 2 - 2
app/Http/Controllers/admin/Operation.php

@@ -34,8 +34,8 @@ class Operation extends Controller
             ]);
             ]);
             $page = request()->input('page', 1);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 15);
             $limit = request()->input('limit', 15);
-            // 排除试玩账号和测试会员(测试代理名下)
-            $query = \App\Support\StatsScope::excludeTestUsers(User::where('users.from','<>', 2), 'users');
+            // 排除游客、模拟会员和测试会员(测试代理名下)
+            $query = \App\Support\StatsScope::realUsers(User::query(), 'users');
             if (isset($params['user_id'])) {
             if (isset($params['user_id'])) {
                 $user_id = $params['user_id'] ?? null;
                 $user_id = $params['user_id'] ?? null;
                 $query->where(function ($query1) use ($user_id) {
                 $query->where(function ($query1) use ($user_id) {

+ 1 - 0
app/Http/Controllers/admin/Order.php

@@ -106,6 +106,7 @@ class Order extends Controller
             $query = OrderModel::join('sport_odds', 'sport_odds.odd_name_en', '=', 'sport_order.odd_name')
             $query = OrderModel::join('sport_odds', 'sport_odds.odd_name_en', '=', 'sport_order.odd_name')
                         ->join('sport', 'sport.data_id', '=', 'sport_order.issue')
                         ->join('sport', 'sport.data_id', '=', 'sport_order.issue')
                         ->join('users', 'users.user_id', '=', 'sport_order.user_id');
                         ->join('users', 'users.user_id', '=', 'sport_order.user_id');
+            \App\Support\MemberAudience::visibleUsers($query, 'users'); // 后台模拟会员只在模拟管理里出现
             if (!empty($params['id'])) {
             if (!empty($params['id'])) {
                 $query = $query->where('sport_order.id', $params['id']);
                 $query = $query->where('sport_order.id', $params['id']);
             }
             }

+ 8 - 6
app/Http/Controllers/admin/PaymentOrder.php

@@ -22,19 +22,21 @@ class PaymentOrder extends Controller
     //统计后台待处理订单及领取审核数(在线平台人数见 GET /admin/online/platformCount)
     //统计后台待处理订单及领取审核数(在线平台人数见 GET /admin/online/platformCount)
     public function unProcessed()
     public function unProcessed()
     {
     {
+        // 后台模拟会员的订单不进待处理角标
+        $real = fn ($query) => \App\Support\MemberAudience::visibleMembers($query, 'member_id');
         //USDT充值订单
         //USDT充值订单
-        $data['usdtRecharge'] = (int)Recharge::where('status', 0)->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
+        $data['usdtRecharge'] = (int)$real(Recharge::where('status', 0))->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
         //人工充值订单
         //人工充值订单
-        $data['rgRecharge'] = (int)PaymentOrderModel::whereIn('status', [0,1,5])->where('type', 3)->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
+        $data['rgRecharge'] = (int)$real(PaymentOrderModel::whereIn('status', [0,1,5])->where('type', 3))->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
         //人民币充值订单
         //人民币充值订单
-        $data['rmbRecharge'] = (int)PaymentOrderModel::whereIn('status', [0,1])->where('type', 1)->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
+        $data['rmbRecharge'] = (int)$real(PaymentOrderModel::whereIn('status', [0,1])->where('type', 1))->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
         
         
         //USDT提现订单
         //USDT提现订单
-        $data['usdtWithdraw'] = (int)Withdraw::where('status', 0)->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
+        $data['usdtWithdraw'] = (int)$real(Withdraw::where('status', 0))->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
         //人工提现订单
         //人工提现订单
-        $data['rgWithdraw'] = (int)PaymentOrderModel::where('status', 0)->where('type', 4)->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
+        $data['rgWithdraw'] = (int)$real(PaymentOrderModel::where('status', 0)->where('type', 4))->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
         //人民币提现订单
         //人民币提现订单
-        $data['rmbWithdraw'] = (int)PaymentOrderModel::where('status', 0)->where('type', 2)->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
+        $data['rmbWithdraw'] = (int)$real(PaymentOrderModel::where('status', 0)->where('type', 2))->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
         // 代理充值待处理:人工充值待发送收款信息/待审核 + USDT 凭证待审核(与会员订单分开统计)。
         // 代理充值待处理:人工充值待发送收款信息/待审核 + USDT 凭证待审核(与会员订单分开统计)。
         // 迁移未执行前(先发代码后迁移)表不存在,捕获后跳过,正常情况下不额外查表结构。
         // 迁移未执行前(先发代码后迁移)表不存在,捕获后跳过,正常情况下不额外查表结构。
         try {
         try {

+ 427 - 0
app/Http/Controllers/admin/Simulation.php

@@ -0,0 +1,427 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\MemberDailyReportService;
+use App\Services\OperationAuditService;
+use App\Services\PaymentOrderService;
+use App\Services\Reporting\ReportSync;
+use App\Services\Sharing\BetFactSynchronizer;
+use App\Services\Simulation\SimulationBetService;
+use App\Services\Simulation\SimulationFundService;
+use App\Services\Simulation\SimulationMemberService;
+use App\Services\Simulation\SimulationResetService;
+use App\Services\Simulation\SimulationSettings;
+use App\Services\Simulation\SimulationTaskService;
+use App\Support\MemberAudience;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\ValidationException;
+
+/**
+ * 模拟管理。只有这里能看到后台模拟会员(users.user_type = 2)的数据:
+ * 列表和报表直接复用正式接口的代码,只是放在 MemberAudience::simulated() 里执行,所以行结构和统计口径与正式页面完全一致。
+ */
+class Simulation extends Controller
+{
+    /** 在“仅模拟会员”口径下调用正式控制器的方法。 */
+    private function mirror(string $controller, string $method): JsonResponse
+    {
+        return MemberAudience::simulated(fn () => app()->call([app($controller), $method]));
+    }
+
+    private function run(callable $work): JsonResponse
+    {
+        try {
+            return $this->success($work());
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (\Throwable $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+
+    private function audit(string $type, $id, string $action, array $after = []): void
+    {
+        OperationAuditService::record('simulation_' . $type, $id === null ? null : (int) $id, $action, [], $after);
+    }
+
+    private function page($query, string $order = 'id'): array
+    {
+        $page = max(1, (int) request()->input('page', 1));
+        $limit = min(200, max(1, (int) request()->input('limit', 15)));
+        return ['total' => (clone $query)->count(), 'data' => $query->orderByDesc($order)->forPage($page, $limit)->get()];
+    }
+
+    // ---------- 概览 ----------
+
+    public function overview()
+    {
+        return $this->run(function () {
+            $start = request()->input('start_date') ?: date('Y-m-d');
+            $end = date('Y-m-d', strtotime((request()->input('end_date') ?: date('Y-m-d')) . ' +1 day'));
+            $members = MemberAudience::simulatedMembers('member_id');
+            $logs = fn (array $types) => (string) DB::table('balance_logs')->whereIn('member_id', $members)->whereIn('change_type', $types)
+                ->where('created_at', '>=', $start)->where('created_at', '<', $end)->sum('amount');
+            $like = fn (string $suffix) => (string) DB::table('balance_logs')->whereIn('member_id', $members)->where('change_type', 'like', '%' . $suffix)
+                ->where('created_at', '>=', $start)->where('created_at', '<', $end)->sum('amount');
+            $bet = bcmul($like('投注'), '-1', 2);
+            $payout = bcadd(bcadd($like('中奖'), $like('退款'), 2), $like('退分'), 2);
+            $wallet = DB::table('wallets')->whereIn('member_id', $members)->selectRaw('COALESCE(SUM(available_balance),0) a, COALESCE(SUM(frozen_balance),0) f')->first();
+            $runs = DB::table('simulation_task_runs')->whereIn('status', ['queued', 'running', 'paused'])->orderByDesc('id')->limit(10)->get();
+            return [
+                'enabled' => SimulationSettings::enabled(), 'migrated' => MemberAudience::enabled(), 'start_date' => $start,
+                'members' => ['total' => DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->count(),
+                    'new' => DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->where('created_at', '>=', $start)->where('created_at', '<', $end)->count(),
+                    'online' => SimulationMemberService::onlineUserIds()->count()],
+                'funds' => ['recharge' => bcadd($logs(['充值', '三方充值']), '0', 2), 'manual_credit' => bcadd($logs(['人工充值']), '0', 2),
+                    'manual_debit' => bcmul($logs(['人工扣款']), '-1', 2), // 提现被拒绝时退款记为正数的“三方提现”,一并计入才是净提现
+                    'withdraw' => bcmul($logs(['提现', '人工提现', '三方提现']), '-1', 2),
+                    'wallet_available' => bcadd((string) $wallet->a, '0', 2), 'wallet_frozen' => bcadd((string) $wallet->f, '0', 2)],
+                'bets' => ['bet_amount' => $bet, 'payout' => $payout, 'win_loss' => bcsub($bet, $payout, 2),
+                    'unsettled' => DB::table('lhc_order')->whereIn('member_id', $members)->where('lottery_status', 0)->count()
+                        + SimulationBetService::sportQuery()->where('sport_order.settlement_status', 0)->where('sport_order.status', 1)->count()],
+                'runs' => $runs->map(fn ($run) => SimulationTaskService::present($run))->all(),
+            ];
+        });
+    }
+
+    // ---------- 会员 ----------
+
+    public function members()
+    {
+        $response = $this->mirror(User::class, 'index');
+        $body = $response->getData(true);
+        if (($body['code'] ?? -1) !== 0 || empty($body['data']['data'])) return $response;
+        $online = array_flip(SimulationMemberService::onlineUserIds()->map(fn ($id) => (string) $id)->all());
+        foreach ($body['data']['data'] as &$row) $row['sim_online'] = isset($online[(string) ($row['user_id'] ?? '')]);
+        return response()->json($body);
+    }
+
+    public function createMembers()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['count' => ['required', 'integer', 'min:1'], 'domain' => ['nullable', 'string', 'max:100'],
+                'initial_balance' => ['nullable', 'numeric', 'min:0']]);
+            $created = SimulationMemberService::create((int) $params['count'], $params);
+            $this->audit('member', null, 'create', ['count' => count($created), 'member_ids' => array_column($created, 'member_id')]);
+            return ['created' => $created];
+        });
+    }
+
+    public function memberDetail()
+    {
+        return $this->run(fn () => SimulationMemberService::detail(request()->validate(['member_id' => ['required', 'string']])['member_id']));
+    }
+
+    public function memberBanned()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['member_id' => ['required', 'string'], 'is_banned' => ['required', 'integer', 'in:0,1']]);
+            SimulationMemberService::setBanned($params['member_id'], (int) $params['is_banned']);
+            $this->audit('member', null, 'banned', $params);
+            return [];
+        });
+    }
+
+    public function memberLogin()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['member_id' => ['required', 'string'], 'platform' => ['nullable', 'string', 'max:16'], 'domain' => ['nullable', 'string', 'max:100']]);
+            return SimulationMemberService::login($params['member_id'], $params['platform'] ?? 'H5', $params['domain'] ?? null);
+        });
+    }
+
+    public function memberLogout()
+    {
+        return $this->run(fn () => ['closed' => SimulationMemberService::logout(request()->validate(['member_id' => ['required', 'string']])['member_id'])]);
+    }
+
+    public function loginLogs() { return $this->mirror(User::class, 'loginLog'); }
+
+    // ---------- 资金 ----------
+
+    private function orders(array $types)
+    {
+        $query = DB::table('payment_orders')->join('users', 'users.member_id', '=', 'payment_orders.member_id')
+            ->where('users.user_type', MemberAudience::TYPE_SIMULATED)->whereIn('payment_orders.type', $types)
+            ->select('payment_orders.id', 'payment_orders.order_no', 'payment_orders.member_id', 'users.account', 'users.first_name', 'payment_orders.type',
+                'payment_orders.amount', 'payment_orders.frozen_amount', 'payment_orders.channel', 'payment_orders.status', 'payment_orders.remark', 'payment_orders.created_at');
+        if (request()->filled('member_id')) $query->where('payment_orders.member_id', (string) request()->input('member_id'));
+        if (request()->filled('account')) $query->where('users.account', 'like', '%' . trim((string) request()->input('account')) . '%');
+        if (request()->filled('status')) $query->where('payment_orders.status', (int) request()->input('status'));
+        if (request()->filled('start_time')) $query->where('payment_orders.created_at', '>=', request()->input('start_time') . ' 00:00:00');
+        if (request()->filled('end_time')) $query->where('payment_orders.created_at', '<=', request()->input('end_time') . ' 23:59:59');
+        return $query;
+    }
+
+    public function recharges()
+    {
+        return $this->run(fn () => $this->page($this->orders([PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_SELF_PAY]), 'payment_orders.id'));
+    }
+
+    public function recharge()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01'],
+                'channel' => ['nullable', 'in:company,third'], 'result' => ['nullable', 'in:success,pending,fail']]);
+            $order = SimulationFundService::recharge($params['member_id'], $params['amount'], $params['channel'] ?? 'third', $params['result'] ?? 'success');
+            $this->audit('fund', $order->id, 'recharge', $params);
+            return $order;
+        });
+    }
+
+    public function withdrawals()
+    {
+        return $this->run(fn () => $this->page($this->orders([PaymentOrderService::TYPE_SELF_PAYOUT]), 'payment_orders.id'));
+    }
+
+    public function withdraw()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01']]);
+            $order = SimulationFundService::withdrawApply($params['member_id'], $params['amount']);
+            $this->audit('fund', $order->id, 'withdraw_apply', $params);
+            return $order;
+        });
+    }
+
+    public function withdrawAudit()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'approve' => ['required', 'integer', 'in:0,1'], 'remark' => ['nullable', 'string', 'max:120']]);
+            $order = SimulationFundService::withdrawAudit((int) $params['id'], (int) $params['approve'] === 1, (string) ($params['remark'] ?? ''));
+            $this->audit('fund', $order->id, (int) $params['approve'] === 1 ? 'withdraw_approve' : 'withdraw_reject', $params);
+            return $order;
+        });
+    }
+
+    public function adjust()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01'],
+                'direction' => ['required', 'in:credit,debit'], 'remark' => ['required', 'string', 'max:120']]);
+            $log = SimulationFundService::adjust($params['member_id'], $params['amount'], $params['direction'], $params['remark']);
+            $this->audit('fund', $log->id, 'adjust_' . $params['direction'], $params);
+            return $log;
+        });
+    }
+
+    public function ledger() { return $this->mirror(Balance::class, 'log'); }
+
+    public function freezes() { return $this->mirror(WalletFreeze::class, 'index'); }
+
+    public function freeze() { return $this->applyFreeze(false); }
+
+    public function unfreeze() { return $this->applyFreeze(true); }
+
+    private function applyFreeze(bool $unfreeze)
+    {
+        return $this->run(function () use ($unfreeze) {
+            $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01'], 'reason' => ['required', 'string', 'max:120']]);
+            $record = SimulationFundService::freeze($params['member_id'], $params['amount'], $params['reason'], (int) (request()->user->id ?? 0), $unfreeze);
+            $this->audit('fund', $record['id'] ?? null, $unfreeze ? 'unfreeze' : 'freeze', $params);
+            return $record;
+        });
+    }
+
+    // ---------- 注单 ----------
+
+    public function betOptions() { return $this->run(fn () => SimulationBetService::options()); }
+
+    public function lotteryBets() { return $this->mirror(LhcOrder::class, 'list'); }
+
+    public function lotteryBet()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['member_id' => ['required', 'string'], 'keywords' => ['required', 'string', 'max:32'], 'amount' => ['required', 'numeric', 'min:0.01']]);
+            $order = SimulationBetService::lottery($params['member_id'], $params['keywords'], $params['amount']);
+            $this->audit('bet', $order->id, 'lottery_bet', $params);
+            return $order;
+        });
+    }
+
+    public function sportBets()
+    {
+        return $this->run(function () {
+            $query = SimulationBetService::sportQuery()->select('sport_order.id', 'sport_order.order_id', 'sport_order.issue', 'users.member_id', 'users.account',
+                'users.first_name', 'sport_order.odd_name', 'sport_order.odd_value', 'sport_order.odd', 'sport_order.amount', 'sport_order.win_amount',
+                'sport_order.return_amount', 'sport_order.profit_and_loss', 'sport_order.is_win', 'sport_order.status', 'sport_order.settlement_status',
+                'sport_order.create_time', 'sport_order.settlement_time');
+            if (request()->filled('member_id')) $query->where('users.member_id', (string) request()->input('member_id'));
+            if (request()->filled('account')) $query->where('users.account', 'like', '%' . trim((string) request()->input('account')) . '%');
+            if (request()->filled('settlement_status')) $query->where('sport_order.settlement_status', (int) request()->input('settlement_status'));
+            if (request()->filled('start_time')) $query->where('sport_order.create_time', '>=', strtotime(request()->input('start_time') . ' 00:00:00'));
+            if (request()->filled('end_time')) $query->where('sport_order.create_time', '<=', strtotime(request()->input('end_time') . ' 23:59:59'));
+            return $this->page($query, 'sport_order.id');
+        });
+    }
+
+    public function sportBet()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01'], 'odd' => ['required', 'numeric'],
+                'title' => ['nullable', 'string', 'max:100'], 'selection' => ['nullable', 'string', 'max:100']]);
+            $order = SimulationBetService::sport($params['member_id'], $params['amount'], $params['odd'], (string) ($params['title'] ?? ''), (string) ($params['selection'] ?? ''));
+            $this->audit('bet', $order->id, 'sport_bet', $params);
+            return $order;
+        });
+    }
+
+    public function sportSettle()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'result' => ['required', 'in:win,lose,draw,half']]);
+            $order = SimulationBetService::settleSport((int) $params['id'], $params['result']);
+            $this->audit('bet', $order->id, 'sport_settle', $params);
+            return $order;
+        });
+    }
+
+    public function sportCancel()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'remark' => ['nullable', 'string', 'max:120']]);
+            $order = SimulationBetService::cancelSport((int) $params['id'], (string) ($params['remark'] ?? ''));
+            $this->audit('bet', $order->id, 'sport_cancel', $params);
+            return $order;
+        });
+    }
+
+    public function thirdBets() { return $this->mirror(ThirdGameOrder::class, 'index'); }
+
+    // ---------- 自动任务 ----------
+
+    public function templates()
+    {
+        return $this->run(function () {
+            $page = $this->page(DB::table('simulation_tasks'));
+            $page['data'] = $page['data']->map(fn ($row) => ['steps' => json_decode($row->steps, true)] + (array) $row)->all();
+            return $page;
+        });
+    }
+
+    public function saveTemplate()
+    {
+        return $this->run(function () {
+            $id = SimulationTaskService::saveTemplate(request()->all(), request()->user->id ?? null);
+            $this->audit('task', $id, 'template_save', ['name' => request()->input('name')]);
+            return ['id' => $id];
+        });
+    }
+
+    public function deleteTemplate()
+    {
+        return $this->run(function () {
+            $id = (int) request()->validate(['id' => ['required', 'integer', 'min:1']])['id'];
+            DB::table('simulation_tasks')->where('id', $id)->delete();
+            $this->audit('task', $id, 'template_delete');
+            return [];
+        });
+    }
+
+    public function runs()
+    {
+        return $this->run(function () {
+            $query = DB::table('simulation_task_runs');
+            if (request()->filled('status')) $query->where('status', (string) request()->input('status'));
+            if (request()->filled('name')) $query->where('name', 'like', '%' . trim((string) request()->input('name')) . '%');
+            $page = $this->page($query);
+            $page['data'] = $page['data']->map(fn ($run) => SimulationTaskService::present($run))->all();
+            return $page;
+        });
+    }
+
+    public function startRun()
+    {
+        return $this->run(function () {
+            $id = SimulationTaskService::start(request()->all(), request()->user->id ?? null);
+            $this->audit('task', $id, 'run_start', ['task_id' => request()->input('task_id'), 'name' => request()->input('name')]);
+            return ['id' => $id];
+        });
+    }
+
+    public function runStatus()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'action' => ['required', 'in:pause,resume,stop']]);
+            $run = SimulationTaskService::setStatus((int) $params['id'], $params['action']);
+            $this->audit('task', $run->id, 'run_' . $params['action']);
+            return SimulationTaskService::present($run);
+        });
+    }
+
+    public function events()
+    {
+        return $this->run(function () {
+            $query = DB::table('simulation_task_events');
+            foreach (['run_id', 'status', 'action', 'member_id'] as $field) if (request()->filled($field)) $query->where($field, request()->input($field));
+            return $this->page($query);
+        });
+    }
+
+    // ---------- 报表:与正式报表同一套代码,只统计模拟会员 ----------
+
+    public function reportUsers() { return $this->mirror(MemberDailyReport::class, 'index'); }
+    public function reportFunds() { return $this->mirror(Funds::class, 'summary'); }
+    public function reportHome() { return $this->mirror(Home::class, 'index'); }
+    public function reportGames() { return $this->mirror(Reporting::class, 'summary'); }
+    public function reportFundFlow() { return $this->mirror(Reporting::class, 'funds'); }
+    public function reportRegistrations() { return $this->mirror(User::class, 'registerTrend'); }
+    public function reportDomains() { return $this->mirror(User::class, 'registerDomain'); }
+
+    /** 立即刷新报表依赖的中间数据(平时由定时任务每 1~10 分钟刷新一次)。 */
+    public function reportRefresh()
+    {
+        return $this->run(function () {
+            $date = request()->validate(['date' => ['nullable', 'date_format:Y-m-d']])['date'] ?? date('Y-m-d');
+            $facts = app(BetFactSynchronizer::class)->sync($date, $date, true);
+            $reporting = app(ReportSync::class)->sync();
+            $daily = app(MemberDailyReportService::class)->refresh($date);
+            return ['date' => $date, 'facts' => $facts, 'reporting' => $reporting, 'member_daily' => $daily];
+        });
+    }
+
+    // ---------- 操作日志 ----------
+
+    public function audits()
+    {
+        return $this->run(function () {
+            $query = DB::table('operation_audits')->where('resource_type', 'like', 'simulation\_%');
+            if (request()->filled('resource_type')) $query->where('resource_type', 'simulation_' . request()->input('resource_type'));
+            if (request()->filled('operator')) $query->where('operator_name', 'like', '%' . trim((string) request()->input('operator')) . '%');
+            if (request()->filled('start_time')) $query->where('created_at', '>=', request()->input('start_time') . ' 00:00:00');
+            if (request()->filled('end_time')) $query->where('created_at', '<=', request()->input('end_time') . ' 23:59:59');
+            $page = $this->page($query);
+            $page['data'] = $page['data']->map(fn ($row) => ['changes' => json_decode((string) $row->changes, true)] + (array) $row)->all();
+            return $page;
+        });
+    }
+
+    // ---------- 设置与重置 ----------
+
+    public function settings() { return $this->run(fn () => SimulationSettings::all()); }
+
+    public function saveSettings()
+    {
+        return $this->run(function () {
+            $change = SimulationSettings::save(request()->all());
+            OperationAuditService::record('simulation_settings', null, 'save', $change['before'], $change['after']);
+            return $change['after'];
+        });
+    }
+
+    public function resetPreview() { return $this->run(fn () => SimulationResetService::preview()); }
+
+    public function reset()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['confirm_token' => ['required', 'string'], 'confirm_text' => ['required', 'string']]);
+            $deleted = SimulationResetService::reset($params['confirm_token'], $params['confirm_text']);
+            $this->audit('reset', null, 'reset', $deleted);
+            return ['deleted' => $deleted];
+        });
+    }
+}

+ 2 - 1
app/Http/Controllers/admin/SportGameOrder.php

@@ -41,6 +41,7 @@ class SportGameOrder extends Controller
             $query = SportGameOrderModel::join('users', 'users.member_id', '=', 'sport_game_order.member_id')
             $query = SportGameOrderModel::join('users', 'users.member_id', '=', 'sport_game_order.member_id')
                         ->join('sport_game', 'sport_game.id', '=', 'sport_game_order.game_id')
                         ->join('sport_game', 'sport_game.id', '=', 'sport_game_order.game_id')
                         ->join('sport_gameplay', 'sport_gameplay.id', '=', 'sport_game_order.gameplay_id');
                         ->join('sport_gameplay', 'sport_gameplay.id', '=', 'sport_game_order.gameplay_id');
+            \App\Support\MemberAudience::visibleUsers($query, 'users'); // 后台模拟会员只在模拟管理里出现
             if (!empty($params['id'])) {
             if (!empty($params['id'])) {
                 $query = $query->where('sport_game_order.id', $params['id']);
                 $query = $query->where('sport_game_order.id', $params['id']);
             }
             }
@@ -90,7 +91,7 @@ class SportGameOrder extends Controller
                 'id' => ['required', 'integer'],
                 'id' => ['required', 'integer'],
             ]);
             ]);
             $id = request()->input('id');
             $id = request()->input('id');
-            $order = SportGameOrderModel::join('users', 'users.member_id', '=', 'sport_game_order.member_id')
+            $order = \App\Support\MemberAudience::visibleUsers(SportGameOrderModel::join('users', 'users.member_id', '=', 'sport_game_order.member_id'), 'users')
                         ->join('sport_game', 'sport_game.id', '=', 'sport_game_order.game_id')
                         ->join('sport_game', 'sport_game.id', '=', 'sport_game_order.game_id')
                         ->join('sport_gameplay', 'sport_gameplay.id', '=', 'sport_game_order.gameplay_id')
                         ->join('sport_gameplay', 'sport_gameplay.id', '=', 'sport_game_order.gameplay_id')
                         ->where('sport_game_order.id', $id)
                         ->where('sport_game_order.id', $id)

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

@@ -533,6 +533,8 @@ class User extends Controller
                 ->where(function ($q) {
                 ->where(function ($q) {
                     $q->whereNull('users.from')->orWhere('users.from', '<>', '0');
                     $q->whereNull('users.from')->orWhere('users.from', '<>', '0');
                 })
                 })
+                // 后台模拟会员的登录日志只在模拟管理里出现;无用户的失败日志保留
+                ->tap(fn ($q) => \App\Support\MemberAudience::visibleMembers($q, 'user_login.user_id', 'user_id'))
                 // 失败日志可能没有用户 ID,仍需按输入账号排除客服来源(from=0)。
                 // 失败日志可能没有用户 ID,仍需按输入账号排除客服来源(from=0)。
                 ->whereNotExists(function ($q) use ($accountMatch) {
                 ->whereNotExists(function ($q) use ($accountMatch) {
                     $q->selectRaw('1')->from('users as customer_service_users')
                     $q->selectRaw('1')->from('users as customer_service_users')

+ 3 - 3
app/Http/Controllers/admin/Wallet.php

@@ -113,13 +113,13 @@ class Wallet extends Controller
             'rmb_withdraw_task' => 0,
             'rmb_withdraw_task' => 0,
         ];
         ];
         if (MenuService::checkMenu(request()->user->id, 'withdraw/topUp')) {
         if (MenuService::checkMenu(request()->user->id, 'withdraw/topUp')) {
-            $data['recharge_task'] = Recharge::where('type', 2)->where('status', 0)->count();
+            $data['recharge_task'] = \App\Support\MemberAudience::visibleMembers(Recharge::where('type', 2)->where('status', 0), 'member_id')->count();
         }
         }
         if (MenuService::checkMenu(request()->user->id, 'listUSDT')) {
         if (MenuService::checkMenu(request()->user->id, 'listUSDT')) {
-            $data['withdraw_task'] = Withdraw::where('status', 0)->count();
+            $data['withdraw_task'] = \App\Support\MemberAudience::visibleMembers(Withdraw::where('status', 0), 'member_id')->count();
         }
         }
         if (MenuService::checkMenu(request()->user->id, 'listRMB')) {
         if (MenuService::checkMenu(request()->user->id, 'listRMB')) {
-            $data['rmb_withdraw_task'] = PaymentOrder::where('type', 2)->where('status', 0)->count();
+            $data['rmb_withdraw_task'] = \App\Support\MemberAudience::visibleMembers(PaymentOrder::where('type', 2)->where('status', 0), 'member_id')->count();
         }
         }
         return $this->success($data);
         return $this->success($data);
     }
     }

+ 2 - 0
app/Http/Controllers/admin/Withdraw.php

@@ -67,6 +67,7 @@ class Withdraw extends Controller
                         
                         
             $where = $this->withRealNameFilter(PaymentOrderService::getWhere($params), $params['real_name'] ?? null);
             $where = $this->withRealNameFilter(PaymentOrderService::getWhere($params), $params['real_name'] ?? null);
             $query->where($where);
             $query->where($where);
+            \App\Support\MemberAudience::visibleUsers($query, 'users'); // 后台模拟会员只在模拟管理里出现
             $count = $query->count();
             $count = $query->count();
             $list = $query
             $list = $query
                 ->orderByRaw('CASE WHEN bot_payment_orders.status = 0 THEN 0 ELSE 1 END')
                 ->orderByRaw('CASE WHEN bot_payment_orders.status = 0 THEN 0 ELSE 1 END')
@@ -142,6 +143,7 @@ class Withdraw extends Controller
                         ->select("withdraws.*", "users.real_name", "users.first_name", "users.account", "users.admin_note as user_admin_note");
                         ->select("withdraws.*", "users.real_name", "users.first_name", "users.account", "users.admin_note as user_admin_note");
             $where = $this->withRealNameFilter(WithdrawService::getWhere($params), $params['real_name'] ?? null);
             $where = $this->withRealNameFilter(WithdrawService::getWhere($params), $params['real_name'] ?? null);
             $query->where($where);
             $query->where($where);
+            \App\Support\MemberAudience::visibleUsers($query, 'users'); // 后台模拟会员只在模拟管理里出现
             $count = $query->count();
             $count = $query->count();
             $list = $query
             $list = $query
                 ->orderByRaw('CASE WHEN bot_withdraws.status = 0 THEN 0 ELSE 1 END')
                 ->orderByRaw('CASE WHEN bot_withdraws.status = 0 THEN 0 ELSE 1 END')

+ 1 - 0
app/Http/Controllers/admin/YueBao.php

@@ -39,6 +39,7 @@ class YueBao extends Controller
 
 
             $query = YuebaoItem::join('users', 'users.user_id', '=', 'yuebao_item.user_id')
             $query = YuebaoItem::join('users', 'users.user_id', '=', 'yuebao_item.user_id')
             ->select("yuebao_item.*", "users.first_name", "users.member_id", "users.account", "users.admin_note as user_admin_note");
             ->select("yuebao_item.*", "users.first_name", "users.member_id", "users.account", "users.admin_note as user_admin_note");
+            \App\Support\MemberAudience::visibleUsers($query, 'users'); // 后台模拟会员只在模拟管理里出现
             if (!empty($params['id'])) {
             if (!empty($params['id'])) {
                 $query = $query->where('yuebao_item.id', $params['id']);
                 $query = $query->where('yuebao_item.id', $params['id']);
             }
             }

+ 1 - 0
app/Models/LhcOrder.php

@@ -18,6 +18,7 @@ class LhcOrder extends BaseModel
 
 
     const SOURCE_TELEGRAM = 'telegram';
     const SOURCE_TELEGRAM = 'telegram';
     const SOURCE_FRONTEND = 'frontend';
     const SOURCE_FRONTEND = 'frontend';
+    const SOURCE_SIMULATION = 'simulation'; // 后台模拟管理生成的注单
 
 
     public function lottery()
     public function lottery()
     {
     {

+ 5 - 0
app/Models/User.php

@@ -24,6 +24,11 @@ use App\Models\Agent\Agent as SystemAgent;
 class User extends BaseModel
 class User extends BaseModel
 {
 {
     protected $table = 'users';
     protected $table = 'users';
+
+    /** users.user_type:0 正常会员 / 1 游客试玩(from = 2)/ 2 后台模拟会员(只在模拟管理里出现)。不在 $fillable 里,不能被接口批量赋值。 */
+    public const TYPE_NORMAL = 0;
+    public const TYPE_GUEST = 1;
+    public const TYPE_SIMULATED = 2;
     protected $fillable = ['usdt', 'is_banned', 'last_active_time', 'visitor_id', 'register_ip', 'register_domain', 'status', 'admin_note', 'member_id', 'first_name', 
     protected $fillable = ['usdt', 'is_banned', 'last_active_time', 'visitor_id', 'register_ip', 'register_domain', 'status', 'admin_note', 'member_id', 'first_name', 
     'game_id', 'username', 'secret_key', 'secret_pass', 'language','user_code', 'agent_user_code', 'agent_id', 'level','recharge_channel_group_id'];
     'game_id', 'username', 'secret_key', 'secret_pass', 'language','user_code', 'agent_user_code', 'agent_id', 'level','recharge_channel_group_id'];
     protected $attributes = [
     protected $attributes = [

+ 1 - 0
app/Services/BackflowService.php

@@ -101,6 +101,7 @@ class BackflowService extends BaseService
         $query = static::$MODEL::join('users', 'users.member_id', '=', 'backflow.member_id')
         $query = static::$MODEL::join('users', 'users.member_id', '=', 'backflow.member_id')
                 ->where(static::getWhere($search))
                 ->where(static::getWhere($search))
                 ->where('backflow.date', '<=', $month);
                 ->where('backflow.date', '<=', $month);
+        \App\Support\MemberAudience::visibleUsers($query, 'users'); // 后台模拟会员只在模拟管理里出现
 
 
         $paginator = $query->select("backflow.*", "users.first_name", "users.account", "users.admin_note as user_admin_note")->orderByDesc('date')->orderBy('status')
         $paginator = $query->select("backflow.*", "users.first_name", "users.account", "users.admin_note as user_admin_note")->orderByDesc('date')->orderBy('status')
             ->paginate($search['limit']);
             ->paginate($search['limit']);

+ 1 - 1
app/Services/BalanceLogService.php

@@ -180,7 +180,7 @@ class BalanceLogService extends BaseService
     private static function writeLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id = null)
     private static function writeLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id = null)
     {
     {
         //回水相关
         //回水相关
-        if (in_array($change_type, static::$computeBackFlowChangeTypes)) {
+        if (in_array($change_type, static::$computeBackFlowChangeTypes) && !\App\Support\MemberAudience::isSimulatedMember($memberId)) { // 模拟会员不累计回水
             BackflowService::updateOrCreate((string)$memberId, floatval($amount));
             BackflowService::updateOrCreate((string)$memberId, floatval($amount));
         }
         }
         
         

+ 14 - 5
app/Services/BetService.php

@@ -3,6 +3,7 @@
 
 
 namespace App\Services;
 namespace App\Services;
 
 
+use App\Support\MemberAudience;
 use App\Constants\StepStatus;
 use App\Constants\StepStatus;
 use App\Constants\Util;
 use App\Constants\Util;
 use App\Models\Message;
 use App\Models\Message;
@@ -808,7 +809,7 @@ class BetService extends BaseService
             $text .= "投注金额:{$v['amount']} \n";
             $text .= "投注金额:{$v['amount']} \n";
             $text .= "投注的资金已退回您的钱包 \n";
             $text .= "投注的资金已退回您的钱包 \n";
 
 
-            self::asyncSendMessage($v['member_id'], $text);
+            if (!MemberAudience::isSimulatedMember($v['member_id'])) self::asyncSendMessage($v['member_id'], $text);
             $item = [];
             $item = [];
             $item['lottery_status'] = self::model()::STATUS_LOSS;
             $item['lottery_status'] = self::model()::STATUS_LOSS;
             self::model()::where('id', $v['id'])->update($item);
             self::model()::where('id', $v['id'])->update($item);
@@ -831,7 +832,8 @@ class BetService extends BaseService
     {
     {
         $list = self::findAll(['issue' => $issue_no, 'lottery_status' => self::model()::STATUS_STAY]);
         $list = self::findAll(['issue' => $issue_no, 'lottery_status' => self::model()::STATUS_STAY]);
         // 大小单双的
         // 大小单双的
-        $otherSum = self::model()::leftJoin('lhc_order_item', 'lhc_order_item.ordernum', '=', 'lhc_order.ordernum')
+        // 后台模拟会员的注额不计入总注(总注决定真实玩家 13/14 的特殊赔率)
+        $otherSum = MemberAudience::visibleMembers(self::model()::leftJoin('lhc_order_item', 'lhc_order_item.ordernum', '=', 'lhc_order.ordernum'), 'lhc_order.member_id')
                     ->whereIn('lhc_order.type', [5, 6])
                     ->whereIn('lhc_order.type', [5, 6])
                     ->where('lhc_order.issue', $issue_no)
                     ->where('lhc_order.issue', $issue_no)
                     ->where('lhc_order.lottery_status', self::model()::STATUS_STAY)
                     ->where('lhc_order.lottery_status', self::model()::STATUS_STAY)
@@ -849,9 +851,13 @@ class BetService extends BaseService
         $noticeNum = rand($betNoticeMini, $betNoticeMax);
         $noticeNum = rand($betNoticeMini, $betNoticeMax);
         $openList = [];
         $openList = [];
         $memberList = [];
         $memberList = [];
+        $simulatedMembers = [];
         
         
         foreach ($list as $k => $settlementOrder) {
         foreach ($list as $k => $settlementOrder) {
             $v = $settlementOrder->toArray();
             $v = $settlementOrder->toArray();
+            // 后台模拟会员:照常结算派奖,但不进群播报、不发私聊、不累计活动投注
+            $simulated = MemberAudience::isSimulatedMember($v['member_id']);
+            if ($simulated) $simulatedMembers[$v['member_id']] = true;
             $order_item = LhcOrderItem::where('ordernum', $v['ordernum'])->get()->toArray();
             $order_item = LhcOrderItem::where('ordernum', $v['ordernum'])->get()->toArray();
             $total_amount = 0;
             $total_amount = 0;
             foreach($order_item as $value) {
             foreach($order_item as $value) {
@@ -859,7 +865,9 @@ class BetService extends BaseService
                 // Payout uses the full stake; net result and effective bet use what the member paid after the coupon.
                 // Payout uses the full stake; net result and effective bet use what the member paid after the coupon.
                 $paid = PromotionCouponService::paid($v['amount'], $value['coupon_discount'] ?? 0);
                 $paid = PromotionCouponService::paid($v['amount'], $value['coupon_discount'] ?? 0);
 
 
-                if (isset($keywordsList[$number])) {
+                if ($simulated) {
+                    // 不计入播报的各玩法注额
+                } elseif (isset($keywordsList[$number])) {
                     $keywordsList[$number] += $v['amount'];
                     $keywordsList[$number] += $v['amount'];
                 } else {
                 } else {
                     $keywordsList[$number] = $v['amount'];
                     $keywordsList[$number] = $v['amount'];
@@ -898,7 +906,7 @@ class BetService extends BaseService
 
 
                     $yl = bcsub($profit, $paid, 2); // 盈利
                     $yl = bcsub($profit, $paid, 2); // 盈利
                     $item['profit_and_loss'] = $yl;
                     $item['profit_and_loss'] = $yl;
-                    if (!in_array('13操', $awards) && !in_array('14操', $awards)) {
+                    if (!$simulated && !in_array('13操', $awards) && !in_array('14操', $awards)) {
                         ActivityUserService::updateBettingAmount($v['member_id'], $paid);
                         ActivityUserService::updateBettingAmount($v['member_id'], $paid);
                     }
                     }
 
 
@@ -936,7 +944,7 @@ class BetService extends BaseService
                         $openList[$v['member_id']]['is_send'] = true;
                         $openList[$v['member_id']]['is_send'] = true;
                     }
                     }
                 } else {
                 } else {
-                    if (!in_array('13操', $awards) && !in_array('14操', $awards)) {
+                    if (!$simulated && !in_array('13操', $awards) && !in_array('14操', $awards)) {
                         ActivityUserService::updateBettingAmount($v['member_id'], $paid);
                         ActivityUserService::updateBettingAmount($v['member_id'], $paid);
                     }
                     }
                     // 未命中的组合不应用 13/14 特殊赔率。
                     // 未命中的组合不应用 13/14 特殊赔率。
@@ -972,6 +980,7 @@ class BetService extends BaseService
             \App\Services\Reporting\ReportContext::lotterySettled((int) $v['id']);
             \App\Services\Reporting\ReportContext::lotterySettled((int) $v['id']);
             AdminRealtime::record('lhc_order', $v['id']);
             AdminRealtime::record('lhc_order', $v['id']);
         }
         }
+        $openList = array_diff_key($openList, $simulatedMembers);
         $openList = array_merge($openList, $fakeOpenData['list']);
         $openList = array_merge($openList, $fakeOpenData['list']);
 
 
         self::lotteryNotice($openList, $issue_no, $keywordsList);
         self::lotteryNotice($openList, $issue_no, $keywordsList);

+ 1 - 0
app/Services/ManualAuditService.php

@@ -318,6 +318,7 @@ class ManualAuditService
     private static function query(array $params = []): Builder
     private static function query(array $params = []): Builder
     {
     {
         $query = ManualAuditRecord::query();
         $query = ManualAuditRecord::query();
+        \App\Support\MemberAudience::visibleMembers($query, 'member_id'); // 后台模拟会员只在模拟管理里出现
 
 
         if (!empty($params['id'])) {
         if (!empty($params['id'])) {
             $query->where('id', (int)$params['id']);
             $query->where('id', (int)$params['id']);

+ 3 - 3
app/Services/MemberDailyReportService.php

@@ -120,9 +120,9 @@ class MemberDailyReportService
         if ($agent) {
         if ($agent) {
             $query->where('r.agent_id', $agent->id);
             $query->where('r.agent_id', $agent->id);
         } else {
         } else {
-            // 总后台:排除试玩账号和测试会员(测试代理名下)
-            \App\Support\StatsScope::excludeTestMembers($query, 'r.user_id', 'id')
-                ->where(fn ($q) => $q->whereNull('u.from')->orWhere('u.from', '<>', 2));
+            // 总后台:排除游客、模拟会员和测试会员(测试代理名下);模拟口径下只留模拟会员
+            \App\Support\StatsScope::excludeTestMembers($query, 'r.user_id', 'id');
+            if (!\App\Support\MemberAudience::isSimulated()) $query->where(fn ($q) => $q->whereNull('u.from')->orWhere('u.from', '<>', 2));
             if (!empty($params['agent_id'])) $query->where('r.agent_id', (int) $params['agent_id']);
             if (!empty($params['agent_id'])) $query->where('r.agent_id', (int) $params['agent_id']);
             if (!empty($params['agent_type'])) $query->where('a.type', (int) $params['agent_type']);
             if (!empty($params['agent_type'])) $query->where('a.type', (int) $params['agent_type']);
         }
         }

+ 11 - 2
app/Services/PaymentOrderService.php

@@ -149,8 +149,8 @@ class PaymentOrderService extends BaseService
     public static function paginate(array $search = [])
     public static function paginate(array $search = [])
     {
     {
         $limit = isset($search['limit']) ? $search['limit'] : 15;
         $limit = isset($search['limit']) ? $search['limit'] : 15;
-        $paginator = static::$MODEL::where(self::getWhere($search))
-            ->join('users', 'users.member_id', '=', 'payment_orders.member_id')
+        $paginator = \App\Support\MemberAudience::visibleUsers(static::$MODEL::where(self::getWhere($search))
+            ->join('users', 'users.member_id', '=', 'payment_orders.member_id'), 'users') // 后台模拟会员只在模拟管理里出现
             ->select("payment_orders.*", "users.first_name", "users.account", "users.admin_note as user_admin_note") // 提前查好需要的字段
             ->select("payment_orders.*", "users.first_name", "users.account", "users.admin_note as user_admin_note") // 提前查好需要的字段
             ->orderByDesc('payment_orders.created_at')
             ->orderByDesc('payment_orders.created_at')
             ->paginate($limit);
             ->paginate($limit);
@@ -713,6 +713,15 @@ class PaymentOrderService extends BaseService
         return $order;
         return $order;
     }
     }
 
 
+    /** 模拟管理:把模拟会员的代收单按“成功回调”入账,与真实回调走同一段入账代码,不向三方发任何请求。 */
+    public static function simulatePaySuccess(PaymentOrder $order): bool
+    {
+        if (!\App\Support\MemberAudience::isSimulatedMember($order->member_id)) {
+            throw new Exception('只能对模拟会员的订单执行模拟入账', HttpStatus::CUSTOM_ERROR);
+        }
+        return self::applyPayCallback($order, $order->amount, 'SIM', 'SIM', null, ['simulation' => true]);
+    }
+
     private static function applyPayCallback(PaymentOrder $info, $payAmount, string $status, string $successStatus, ?string $failStatus, array $params): bool
     private static function applyPayCallback(PaymentOrder $info, $payAmount, string $status, string $successStatus, ?string $failStatus, array $params): bool
     {
     {
         return DB::transaction(function () use ($info, $payAmount, $status, $successStatus, $failStatus, $params) {
         return DB::transaction(function () use ($info, $payAmount, $status, $successStatus, $failStatus, $params) {

+ 1 - 1
app/Services/RechargeService.php

@@ -119,7 +119,7 @@ class RechargeService extends BaseService
     {
     {
         $limit = isset($search['limit']) ? $search['limit'] : 15;
         $limit = isset($search['limit']) ? $search['limit'] : 15;
         $first_name = $search['first_name'] ?? '';
         $first_name = $search['first_name'] ?? '';
-        $paginator = static::$MODEL::where(self::getWhere($search))
+        $paginator = \App\Support\MemberAudience::visibleMembers(static::$MODEL::where(self::getWhere($search)), 'member_id') // 后台模拟会员只在模拟管理里出现
             ->with(['member'])
             ->with(['member'])
             ->tap(fn($query) => MemberAccount::filter($query, $search['account'] ?? null))
             ->tap(fn($query) => MemberAccount::filter($query, $search['account'] ?? null))
             ->when($first_name, function ($query) use ($first_name) {
             ->when($first_name, function ($query) use ($first_name) {

+ 1 - 0
app/Services/Reporting/ReportAdmin.php

@@ -42,6 +42,7 @@ class ReportAdmin
         $p = Validator::make($input, ['page' => ['integer', 'min:1', 'max:1000000'], 'limit' => ['integer', 'min:1', 'max:100'],
         $p = Validator::make($input, ['page' => ['integer', 'min:1', 'max:1000000'], 'limit' => ['integer', 'min:1', 'max:100'],
             'status' => ['nullable', Rule::in(['all', 'pending', 'verified'])], 'kind' => ['nullable', Rule::in(['deposit', 'withdraw', 'agent_withdraw'])]])->validate();
             'status' => ['nullable', Rule::in(['all', 'pending', 'verified'])], 'kind' => ['nullable', Rule::in(['deposit', 'withdraw', 'agent_withdraw'])]])->validate();
         $query = DB::table('reporting_events as e')->leftJoin('reporting_reconciliations as r', 'r.event_id', '=', 'e.id')->whereIn('e.kind', ['deposit', 'withdraw', 'agent_withdraw']);
         $query = DB::table('reporting_events as e')->leftJoin('reporting_reconciliations as r', 'r.event_id', '=', 'e.id')->whereIn('e.kind', ['deposit', 'withdraw', 'agent_withdraw']);
+        \App\Support\MemberAudience::visibleMembers($query, 'e.user_id', 'id'); // 模拟会员的收付款不进核对列表
         if (($p['status'] ?? 'all') === 'pending') $query->whereNull('r.id');
         if (($p['status'] ?? 'all') === 'pending') $query->whereNull('r.id');
         if (($p['status'] ?? 'all') === 'verified') $query->whereNotNull('r.id');
         if (($p['status'] ?? 'all') === 'verified') $query->whereNotNull('r.id');
         if (!empty($p['kind'])) $query->where('e.kind', $p['kind']);
         if (!empty($p['kind'])) $query->where('e.kind', $p['kind']);

+ 1 - 1
app/Services/Reporting/ReportContext.php

@@ -102,7 +102,7 @@ class ReportContext
         else return ['user_id' => null, 'agent_id' => $source === 'agent_withdrawals' ? ($row['agent_id'] ?? null) : null,
         else return ['user_id' => null, 'agent_id' => $source === 'agent_withdrawals' ? ($row['agent_id'] ?? null) : null,
             'inviter_id' => null, 'client_platform' => 'unknown', 'attribution_quality' => 'unknown'];
             'inviter_id' => null, 'client_platform' => 'unknown', 'attribution_quality' => 'unknown'];
         $users = $query->limit(2)->get();
         $users = $query->limit(2)->get();
-        if ($users->count() !== 1 || (int) ($users[0]->from ?? 0) === 2) throw new \RuntimeException('真实会员身份不明确');
+        if ($users->count() !== 1 || ((int) ($users[0]->from ?? 0) === 2 && (int) ($users[0]->user_type ?? 0) !== \App\Support\MemberAudience::TYPE_SIMULATED)) throw new \RuntimeException('真实会员身份不明确');
         $user = (array) $users[0]; $inviter = null;
         $user = (array) $users[0]; $inviter = null;
         if (Schema::hasTable('share_members')) {
         if (Schema::hasTable('share_members')) {
             $relation = DB::table('share_members')->where('user_id', $user['id'])->where('bound_at', '<=', $at)->first();
             $relation = DB::table('share_members')->where('user_id', $user['id'])->where('bound_at', '<=', $at)->first();

+ 2 - 0
app/Services/Reporting/ReportReader.php

@@ -328,6 +328,8 @@ class ReportReader
     {
     {
         $uid = $row['user_id'] ?? null; $agent = $row['agent_id'] ?? null; $inviter = $row['inviter_id'] ?? null;
         $uid = $row['user_id'] ?? null; $agent = $row['agent_id'] ?? null; $inviter = $row['inviter_id'] ?? null;
         if ($uid !== null && !isset($users[$uid])) return false;
         if ($uid !== null && !isset($users[$uid])) return false;
+        // 模拟口径只看模拟会员自己的数据,没有会员的事件(代理资金)一律不计
+        if ($uid === null && \App\Support\MemberAudience::isSimulated()) return false;
         // 只有代理、没有会员的事件(代理提现、代理分成):测试代理的不统计
         // 只有代理、没有会员的事件(代理提现、代理分成):测试代理的不统计
         if ($uid === null && $agent && !empty($agents[$agent]['is_test'])) return false;
         if ($uid === null && $agent && !empty($agents[$agent]['is_test'])) return false;
         if ($p['platform'] !== 'all' && ($row['client_platform'] ?? 'unknown') !== $p['platform']) return false;
         if ($p['platform'] !== 'all' && ($row['client_platform'] ?? 'unknown') !== $p['platform']) return false;

+ 2 - 1
app/Services/Reporting/ReportSync.php

@@ -117,7 +117,8 @@ class ReportSync
             if (!$record) throw new \RuntimeException('原记录不存在');
             if (!$record) throw new \RuntimeException('原记录不存在');
             $row = (array) $record;
             $row = (array) $record;
             $member = $row['member_id'] ?? null;
             $member = $row['member_id'] ?? null;
-            $virtual = $member !== null && DB::table('users')->where('member_id', (string) $member)->where('from', 2)->exists();
+            $virtual = $member !== null && DB::table('users')->where('member_id', (string) $member)->where('from', 2)->exists()
+                && !\App\Support\MemberAudience::isSimulatedMember($member); // 游客不投影;后台模拟会员照常投影,读取方按口径隔离
             if ($virtual) {
             if ($virtual) {
                 DB::table('reporting_orders')->where('parent_source', $source)->where('parent_id', (string) $id)->delete();
                 DB::table('reporting_orders')->where('parent_source', $source)->where('parent_id', (string) $id)->delete();
                 DB::table('reporting_events')->where('source', $source)->where('source_id', (string) $id)->delete();
                 DB::table('reporting_events')->where('source', $source)->where('source_id', (string) $id)->delete();

+ 1 - 0
app/Services/RewardBaseSyncService.php

@@ -53,6 +53,7 @@ class RewardBaseSyncService
                 try {
                 try {
                     $user = $users[$userId] ??= DB::table('users')->where('id', $userId)->first(['id', 'member_id', 'level', 'username', 'first_name']);
                     $user = $users[$userId] ??= DB::table('users')->where('id', $userId)->first(['id', 'member_id', 'level', 'username', 'first_name']);
                     if (!$user) continue;
                     if (!$user) continue;
+                    if (\App\Support\MemberAudience::isSimulatedMember($user->member_id)) continue; // 模拟会员不产生返水、回水
                     if ($type === 'rebate' ? $this->rebate($user, $period) : $this->backflow($user, $period)) {
                     if ($type === 'rebate' ? $this->rebate($user, $period) : $this->backflow($user, $period)) {
                         ++$result[$type === 'rebate' ? 'rebates' : 'backflow'];
                         ++$result[$type === 'rebate' ? 'rebates' : 'backflow'];
                     }
                     }

+ 3 - 1
app/Services/Sharing/BetFactSynchronizer.php

@@ -341,7 +341,9 @@ class BetFactSynchronizer
         if ($rows->count() !== 1) throw new RuntimeException('User mapping is missing or ambiguous');
         if ($rows->count() !== 1) throw new RuntimeException('User mapping is missing or ambiguous');
         $user = $rows->first();
         $user = $rows->first();
         if ($member !== '' && (string) $user->member_id !== $member) throw new RuntimeException('User identity mismatch');
         if ($member !== '' && (string) $user->member_id !== $member) throw new RuntimeException('User identity mismatch');
-        return $this->users[$key] = ((int) ($user->from ?? 0) === 2 ? null : $user);
+        // 游客不进投注事实;后台模拟会员(user_type = 2)照常进,读取方按口径隔离
+        $guest = (int) ($user->from ?? 0) === 2 && (int) ($user->user_type ?? 0) !== \App\Support\MemberAudience::TYPE_SIMULATED;
+        return $this->users[$key] = ($guest ? null : $user);
     }
     }
 
 
     private function refundAmount(object $order, array $types, ?string $member = null): string
     private function refundAmount(object $order, array $types, ?string $member = null): string

+ 2 - 2
app/Services/Sharing/ShareSettlementService.php

@@ -27,7 +27,7 @@ class ShareSettlementService
         foreach (DB::table('share_tree')->where('depth',1)->where('bound_at','<=',$end)->cursor() as $edge) {
         foreach (DB::table('share_tree')->where('depth',1)->where('bound_at','<=',$end)->cursor() as $edge) {
             if (isset($qualified[(int)$edge->descendant_user_id]) && $users->has($edge->ancestor_user_id)) $counts[(int)$edge->ancestor_user_id]=($counts[(int)$edge->ancestor_user_id] ?? 0)+1;
             if (isset($qualified[(int)$edge->descendant_user_id]) && $users->has($edge->ancestor_user_id)) $counts[(int)$edge->ancestor_user_id]=($counts[(int)$edge->ancestor_user_id] ?? 0)+1;
         }
         }
-        $facts = DB::table('share_bet_facts')->where('bet_date',$date)->where('settled',1)->where('voided',0)->where('currency',$currency)->where('effective_amount','>',0)->get();
+        $facts = \App\Support\MemberAudience::visibleMembers(DB::table('share_bet_facts'),'user_id','id')->where('bet_date',$date)->where('settled',1)->where('voided',0)->where('currency',$currency)->where('effective_amount','>',0)->get();
         $edges = [];
         $edges = [];
         foreach (array_chunk($facts->pluck('user_id')->unique()->all(),500) as $ids) {
         foreach (array_chunk($facts->pluck('user_id')->unique()->all(),500) as $ids) {
             foreach (DB::table('share_tree')->whereIn('descendant_user_id',$ids)->where('depth','>',0)->get() as $edge) $edges[(int)$edge->descendant_user_id][]=$edge;
             foreach (DB::table('share_tree')->whereIn('descendant_user_id',$ids)->where('depth','>',0)->get() as $edge) $edges[(int)$edge->descendant_user_id][]=$edge;
@@ -122,7 +122,7 @@ class ShareSettlementService
             if (!$depthRates && $policy->indirect_bonus_rate===null) $blockers[]=['code'=>'legacy_policy_incomplete','message'=>'历史计佣版本缺少当时的间接佣金参数'];
             if (!$depthRates && $policy->indirect_bonus_rate===null) $blockers[]=['code'=>'legacy_policy_incomplete','message'=>'历史计佣版本缺少当时的间接佣金参数'];
         }
         }
         $currency=(string)config('third_game.currency','CNY');
         $currency=(string)config('third_game.currency','CNY');
-        $stats=DB::table('share_bet_facts')->where('bet_date',$date)->select('source')->selectRaw(
+        $stats=\App\Support\MemberAudience::visibleMembers(DB::table('share_bet_facts'),'user_id','id')->where('bet_date',$date)->select('source')->selectRaw(
             'COUNT(*) AS total, SUM(CASE WHEN settled = 0 THEN 1 ELSE 0 END) AS unsettled,
             'COUNT(*) AS total, SUM(CASE WHEN settled = 0 THEN 1 ELSE 0 END) AS unsettled,
             SUM(CASE WHEN voided = 1 THEN 1 ELSE 0 END) AS voided,
             SUM(CASE WHEN voided = 1 THEN 1 ELSE 0 END) AS voided,
             SUM(CASE WHEN currency IS NULL OR currency <> ? THEN 1 ELSE 0 END) AS currency_mismatch,
             SUM(CASE WHEN currency IS NULL OR currency <> ? THEN 1 ELSE 0 END) AS currency_mismatch,

+ 168 - 0
app/Services/Simulation/SimulationBetService.php

@@ -0,0 +1,168 @@
+<?php
+
+namespace App\Services\Simulation;
+
+use App\Constants\HttpStatus;
+use App\Models\Config;
+use App\Models\LhcOrder;
+use App\Models\LhcOrderItem;
+use App\Models\Order;
+use App\Models\PcIssue;
+use App\Models\Wallet;
+use App\Services\BalanceLogService;
+use App\Services\GameplayRuleService;
+use App\Services\IssueService;
+use App\Support\MemberAudience;
+use Exception;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 模拟会员的注单,写真实注单表并产生真实账变。
+ * 彩票:PC28(加拿大 type 5 / 极速 type 6,跟随当前 pc28_switch),下在真实期号上,随真实开奖由 BetService::betSettled 结算。
+ * 体育:sport_order,issue 用不存在于赛事表的合成值,所以前台的结算任务取不到它,只能在这里按给定结果结算。
+ */
+final class SimulationBetService
+{
+    public const SPORT_ISSUE_PREFIX = 'SIM';
+    public const SPORT_RESULTS = ['win' => 1, 'lose' => 0, 'draw' => 2, 'half' => 3];
+
+    /** 当前可下注的 PC28 期号与可用玩法。 */
+    public static function options(): array
+    {
+        [$type, $issue] = self::currentIssue();
+        $rules = GameplayRuleService::model()::query()->where('odds', '>', 0)->get(['keywords', 'odds', 'mininum', 'maxinum']);
+        return ['lottery' => ['type' => $type, 'issue' => $issue ? $issue->issue_no : null, 'end_time' => $issue ? (string) $issue->end_time : null, 'rules' => $rules],
+            'sport' => ['results' => array_keys(self::SPORT_RESULTS)]];
+    }
+
+    private static function currentIssue(): array
+    {
+        $switch = (int) (Config::where('field', 'pc28_switch')->value('val') ?? 0);
+        $issue = $switch === 1
+            ? PcIssue::where('status', PcIssue::STATUS_BETTING)->orderByDesc('id')->first()
+            : IssueService::model()::where('status', IssueService::model()::STATUS_BETTING)->orderByDesc('id')->first();
+        return [$switch === 1 ? 6 : 5, $issue];
+    }
+
+    /** PC28 下注,校验与 BetService::singleNote 一致,但不发任何群消息或私聊。 */
+    public static function lottery($memberId, string $keywords, $amount): LhcOrder
+    {
+        SimulationSettings::assertEnabled();
+        $user = SimulationSettings::member($memberId);
+        $amount = SimulationSettings::assertAmount($amount);
+        $rule = GameplayRuleService::getGameplayRules($keywords);
+        if (!$rule) throw new Exception('玩法未配置:' . $keywords, HttpStatus::CUSTOM_ERROR);
+        if ($rule['odds'] <= 0) throw new Exception('该玩法赔率为 0,禁止投注', HttpStatus::CUSTOM_ERROR);
+        [$type, $issue] = self::currentIssue();
+        if (!$issue) throw new Exception('暂无可下注期数', HttpStatus::CUSTOM_ERROR);
+        if ((string) $issue->end_time < date('Y-m-d H:i:s', time() + 30)) throw new Exception('封盘中,本次下注无效', HttpStatus::CUSTOM_ERROR);
+        if ($amount < $rule['mininum']) throw new Exception('低于该玩法最小金额 ' . $rule['mininum'], HttpStatus::CUSTOM_ERROR);
+        if ($amount > $rule['maxinum']) throw new Exception('超过该玩法最大金额 ' . $rule['maxinum'], HttpStatus::CUSTOM_ERROR);
+        return DB::transaction(function () use ($user, $keywords, $amount, $rule, $type, $issue) {
+            $wallet = Wallet::where('member_id', $user->member_id)->lockForUpdate()->first();
+            if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
+            $before = (string) $wallet->available_balance;
+            if (bccomp($before, $amount, 10) < 0) throw new Exception('余额不足', HttpStatus::CUSTOM_ERROR);
+            $ordernum = date('YmdHis') . str_pad((string) mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
+            LhcOrderItem::create(['ordernum' => $ordernum, 'member_id' => $user->member_id, 'number' => $keywords]);
+            $order = LhcOrder::create(['type' => $type, 'bet_source' => LhcOrder::SOURCE_SIMULATION, 'issue' => $issue->issue_no, 'ordernum' => $ordernum,
+                'member_id' => $user->member_id, 'odds' => $rule['odds'], 'amount' => $amount, 'total_amount' => $amount]);
+            $after = bcsub($before, $amount, 10);
+            $wallet->available_balance = $after;
+            $wallet->save();
+            BalanceLogService::addLog($user->member_id, bcsub('0', $amount, 2), $before, $after, '投注', $order->id, '模拟投注');
+            return $order;
+        });
+    }
+
+    /** 生成一张体育注单并扣款。 */
+    public static function sport($memberId, $amount, $odd, string $title = '', string $selection = ''): Order
+    {
+        SimulationSettings::assertEnabled();
+        $user = SimulationSettings::member($memberId);
+        $amount = SimulationSettings::assertAmount($amount);
+        if (!is_numeric($odd) || $odd <= 1 || $odd > 100) throw new Exception('赔率应大于 1 且不超过 100', HttpStatus::CUSTOM_ERROR);
+        $title = $title !== '' ? $title : '模拟主队 VS 模拟客队';
+        $selection = $selection !== '' ? $selection : '主胜';
+        return DB::transaction(function () use ($user, $amount, $odd, $title, $selection) {
+            $wallet = Wallet::where('member_id', $user->member_id)->lockForUpdate()->first();
+            if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
+            $before = (string) $wallet->available_balance;
+            if (bccomp($before, $amount, 10) < 0) throw new Exception('余额不足', HttpStatus::CUSTOM_ERROR);
+            $serial = date('YmdHis') . str_pad((string) mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
+            $order = (new Order())->forceFill(['user_id' => (string) $user->user_id, 'order_id' => 'S' . $serial, 'issue' => self::SPORT_ISSUE_PREFIX . $serial,
+                'detail' => json_encode(['simulation' => true, 'title' => $title, 'selection' => $selection], JSON_UNESCAPED_UNICODE),
+                'amount' => $amount, 'cny_rate' => 1, 'is_faker' => 0, 'status' => 1, 'pay_status' => 1, 'pay_time' => time(),
+                'odd_name' => $title, 'odd_value' => $selection, 'odd' => $odd, 'is_roll' => 0, 'remark' => '模拟注单']);
+            $order->save();
+            $after = bcsub($before, $amount, 10);
+            $wallet->available_balance = $after;
+            $wallet->save();
+            BalanceLogService::addLog($user->member_id, bcsub('0', $amount, 2), $before, $after, '体彩投注', $order->id, '模拟投注');
+            return $order;
+        });
+    }
+
+    /** 只允许操作模拟会员自己的、合成期号的体育注单。 */
+    private static function sportOrder(int $id): Order
+    {
+        $order = Order::where('id', $id)->lockForUpdate()->first();
+        if (!$order || !str_starts_with((string) $order->issue, self::SPORT_ISSUE_PREFIX)
+            || !DB::table('users')->where('user_id', (string) $order->user_id)->where('user_type', MemberAudience::TYPE_SIMULATED)->exists()) {
+            throw new Exception('不是模拟体育注单', HttpStatus::CUSTOM_ERROR);
+        }
+        if ((int) $order->settlement_status !== 0 || (int) $order->status !== 1) throw new Exception('注单已结算或已取消', HttpStatus::CUSTOM_ERROR);
+        return $order;
+    }
+
+    /** 按给定结果结算:赢=本金×赔率,和局退本金,输半退一半,输不退。金额口径与前台结算一致。 */
+    public static function settleSport(int $id, string $result): Order
+    {
+        SimulationSettings::assertEnabled();
+        if (!isset(self::SPORT_RESULTS[$result])) throw new Exception('结算结果不正确', HttpStatus::CUSTOM_ERROR);
+        return DB::transaction(function () use ($id, $result) {
+            $order = self::sportOrder($id);
+            $amount = (string) $order->amount;
+            $win = '0'; $return = '0'; $profit = bcsub('0', $amount, 2); $changeType = null;
+            if ($result === 'win') { $win = bcmul($amount, (string) $order->odd, 2); $profit = bcsub($win, $amount, 2); $changeType = '体彩中奖'; }
+            if ($result === 'draw') { $return = $amount; $profit = '0'; $changeType = '体彩和局退款'; }
+            if ($result === 'half') { $return = bcdiv($amount, '2', 2); $profit = bcsub($return, $amount, 2); $changeType = '体彩输半退款'; }
+            $order->forceFill(['is_win' => self::SPORT_RESULTS[$result], 'win_amount' => $win, 'return_amount' => $return, 'profit_and_loss' => $profit,
+                'settlement_status' => 1, 'settlement_time' => time(), 'game_result' => $result])->save();
+            $credit = bcadd($win, $return, 2);
+            if ($changeType !== null && bccomp($credit, '0', 2) > 0) self::credit((string) $order->user_id, $credit, $changeType, (int) $order->id);
+            return $order;
+        });
+    }
+
+    public static function cancelSport(int $id, string $remark = ''): Order
+    {
+        SimulationSettings::assertEnabled();
+        return DB::transaction(function () use ($id, $remark) {
+            $order = self::sportOrder($id);
+            $order->forceFill(['status' => 2, 'return_amount' => $order->amount, 'profit_and_loss' => 0, 'settlement_status' => 1,
+                'settlement_time' => time(), 'remark' => $remark !== '' ? $remark : '模拟注单撤销'])->save();
+            self::credit((string) $order->user_id, (string) $order->amount, '体彩退款', (int) $order->id);
+            return $order;
+        });
+    }
+
+    private static function credit(string $userId, string $amount, string $changeType, int $orderId): void
+    {
+        $memberId = DB::table('users')->where('user_id', $userId)->value('member_id');
+        $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
+        if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
+        $before = (string) $wallet->available_balance;
+        $after = bcadd($before, $amount, 10);
+        $wallet->available_balance = $after;
+        $wallet->save();
+        BalanceLogService::addLog($memberId, $amount, $before, $after, $changeType, $orderId, '模拟结算');
+    }
+
+    /** 模拟体育注单查询(合成期号没有赛事,不能复用正式列表的赛事关联)。 */
+    public static function sportQuery()
+    {
+        return DB::table('sport_order')->join('users', 'users.user_id', '=', 'sport_order.user_id')
+            ->where('users.user_type', MemberAudience::TYPE_SIMULATED);
+    }
+}

+ 143 - 0
app/Services/Simulation/SimulationFundService.php

@@ -0,0 +1,143 @@
+<?php
+
+namespace App\Services\Simulation;
+
+use App\Constants\HttpStatus;
+use App\Models\PaymentOrder;
+use App\Models\Wallet;
+use App\Services\BalanceLogService;
+use App\Services\PaymentOrderService;
+use App\Services\WageringService;
+use App\Services\WalletFreezeService;
+use App\Services\WithdrawFundsService;
+use Exception;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 模拟会员的资金:订单、流水、冻结都写真实业务表并走真实入账代码,只是不向任何三方发请求。
+ * 充值有两条路:公司入款(人工充值单审核通过)和三方充值(代收单按成功回调入账);提现走人工提现。
+ */
+final class SimulationFundService
+{
+    public const CHANNEL = 'simulation';
+
+    /** @param string $channel company|third  @param string $result success|pending|fail */
+    public static function recharge($memberId, $amount, string $channel = 'third', string $result = 'success'): PaymentOrder
+    {
+        SimulationSettings::assertEnabled();
+        $user = SimulationSettings::member($memberId);
+        $amount = SimulationSettings::assertAmount($amount);
+        $type = $channel === 'company' ? PaymentOrderService::TYPE_SELF_PAY : PaymentOrderService::TYPE_PAY;
+        $order = PaymentOrder::create(['type' => $type, 'member_id' => $user->member_id, 'amount' => $amount, 'channel' => self::CHANNEL,
+            'order_no' => PaymentOrderService::createOrderNo('sim' . $type . '_', $user->member_id), 'remark' => '模拟充值',
+            'status' => $type === PaymentOrderService::TYPE_SELF_PAY ? PaymentOrderService::STATUS_AUDIT : PaymentOrderService::STATUS_PROCESS]
+            + PaymentOrderService::feeFields($amount, 0));
+        if ($result === 'fail') {
+            $order->status = PaymentOrderService::STATUS_FAIL;
+            $order->remark = '模拟充值失败';
+            $order->save();
+        } elseif ($result === 'success') {
+            if ($type === PaymentOrderService::TYPE_PAY) {
+                if (!PaymentOrderService::simulatePaySuccess($order)) throw new Exception('模拟充值入账失败', HttpStatus::CUSTOM_ERROR);
+            } else {
+                self::approveCompanyDeposit((int) $order->id);
+            }
+        }
+        return $order->refresh();
+    }
+
+    /** 与后台“人工充值审核通过”相同的入账:订单置成功、加可用余额、写“充值”流水。 */
+    private static function approveCompanyDeposit(int $id): void
+    {
+        DB::transaction(function () use ($id) {
+            $order = PaymentOrder::where('id', $id)->where('type', PaymentOrderService::TYPE_SELF_PAY)->lockForUpdate()->first();
+            if (!$order || (int) $order->status !== PaymentOrderService::STATUS_AUDIT) throw new Exception('订单状态不可审核', HttpStatus::CUSTOM_ERROR);
+            $order->status = PaymentOrderService::STATUS_SUCCESS;
+            $order->state = 1;
+            $order->save();
+            $wallet = Wallet::where('member_id', $order->member_id)->lockForUpdate()->first();
+            if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
+            $before = (string) $wallet->available_balance;
+            $after = bcadd($before, (string) $order->amount, 10);
+            $wallet->available_balance = $after;
+            $wallet->save();
+            $log = BalanceLogService::addLog($order->member_id, $order->amount, $before, $after, '充值', $order->id, '模拟充值');
+            \App\Services\RechargeBonusService::grant('payment_order', $order, $log);
+        });
+    }
+
+    /** 人工提现申请:校验打码量、冻结资金、建待审核提现单。 */
+    public static function withdrawApply($memberId, $amount): PaymentOrder
+    {
+        SimulationSettings::assertEnabled();
+        $user = SimulationSettings::member($memberId);
+        $amount = SimulationSettings::assertAmount($amount);
+        if (SimulationSettings::get('simulation_wager_bypass') === '1' && WageringService::enabled()) {
+            $wallet = Wallet::where('member_id', $user->member_id)->first();
+            if ($wallet) WageringService::resetCycle($wallet, 'simulation', null, '模拟提现跳过打码量');
+        }
+        $data = ['type' => PaymentOrderService::TYPE_SELF_PAYOUT, 'member_id' => $user->member_id, 'amount' => $amount, 'channel' => self::CHANNEL,
+            'order_no' => PaymentOrderService::createOrderNo('sim4_', $user->member_id), 'bank_name' => '模拟银行', 'account' => (string) $user->first_name,
+            'card_no' => 'SIM' . $user->member_id, 'status' => PaymentOrderService::STATUS_STAY] + PaymentOrderService::feeFields($amount, 0);
+        [$order] = WithdrawFundsService::hold((string) $user->member_id, $amount, '人工提现', '模拟提现', WithdrawFundsService::PAYMENT_ORDERS,
+            function () use ($data, $user) {
+                $pending = PaymentOrder::where('type', PaymentOrderService::TYPE_SELF_PAYOUT)->where('member_id', $user->member_id)
+                    ->where('status', PaymentOrderService::STATUS_STAY)->lockForUpdate()->first(['id']);
+                if ($pending) throw new Exception('该模拟会员已有一笔待审核的提现', HttpStatus::CUSTOM_ERROR);
+                return PaymentOrder::create($data);
+            });
+        return $order->refresh();
+    }
+
+    /** 审核模拟提现:通过则扣除冻结余额,拒绝则退回可用余额。 */
+    public static function withdrawAudit(int $id, bool $approve, string $remark = ''): PaymentOrder
+    {
+        SimulationSettings::assertEnabled();
+        $order = PaymentOrder::where('id', $id)->where('type', PaymentOrderService::TYPE_SELF_PAYOUT)->first();
+        if (!$order) throw new Exception('提现单不存在', HttpStatus::CUSTOM_ERROR);
+        SimulationSettings::member($order->member_id);
+        if ($approve) {
+            DB::transaction(function () use ($id) {
+                $locked = PaymentOrder::where('id', $id)->where('status', PaymentOrderService::STATUS_STAY)->lockForUpdate()->first();
+                if (!$locked) throw new Exception('提现单已处理', HttpStatus::CUSTOM_ERROR);
+                $locked->status = PaymentOrderService::STATUS_SUCCESS;
+                $locked->save();
+                WithdrawFundsService::settle(WithdrawFundsService::PAYMENT_ORDERS, $locked);
+            });
+        } else {
+            $result = PaymentOrderService::withdrawalFailed($id, $remark !== '' ? $remark : '模拟提现拒绝');
+            if ($result['code'] !== 0) throw new Exception($result['msg'], HttpStatus::CUSTOM_ERROR);
+        }
+        return $order->refresh();
+    }
+
+    /** 人工调账:加款记“人工充值”,扣款记“人工扣款”,与后台上下分一致。 */
+    public static function adjust($memberId, $amount, string $direction, string $remark)
+    {
+        SimulationSettings::assertEnabled();
+        $user = SimulationSettings::member($memberId);
+        $amount = SimulationSettings::assertAmount($amount);
+        return DB::transaction(function () use ($user, $amount, $direction, $remark) {
+            $wallet = Wallet::where('member_id', $user->member_id)->lockForUpdate()->first();
+            if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
+            $delta = $direction === 'debit' ? bcsub('0', $amount, 2) : $amount;
+            $before = (string) $wallet->available_balance;
+            $after = bcadd($before, $delta, 10);
+            if (bccomp($after, '0', 10) < 0) throw new Exception('可用余额不足', HttpStatus::CUSTOM_ERROR);
+            $wallet->available_balance = $after;
+            $wallet->save();
+            return BalanceLogService::addLog($user->member_id, $delta, $before, $after, $direction === 'debit' ? '人工扣款' : '人工充值', null, $remark);
+        });
+    }
+
+    public static function freeze($memberId, $amount, string $reason, int $adminId, bool $unfreeze = false): array
+    {
+        SimulationSettings::assertEnabled();
+        $user = SimulationSettings::member($memberId);
+        $amount = SimulationSettings::assertAmount($amount);
+        $requestId = 'sim-' . bin2hex(random_bytes(8));
+        return $unfreeze
+            ? WalletFreezeService::unfreeze($requestId, $adminId, (string) $user->member_id, $amount, '0', $reason)
+            : WalletFreezeService::freeze($requestId, $adminId, (string) $user->member_id, $amount, '0', $reason);
+    }
+}

+ 117 - 0
app/Services/Simulation/SimulationMemberService.php

@@ -0,0 +1,117 @@
+<?php
+
+namespace App\Services\Simulation;
+
+use App\Constants\HttpStatus;
+use App\Models\User;
+use App\Models\UserLogin;
+use App\Models\Wallet;
+use App\Support\MemberAudience;
+use Exception;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/** 后台模拟会员:users.user_type = 2、from = 2、没有代理和上级,不能登录前台。 */
+final class SimulationMemberService
+{
+    /** @return list<array{id:int,member_id:string,account:string}> */
+    public static function create(int $count, array $options = []): array
+    {
+        SimulationSettings::assertEnabled();
+        $settings = SimulationSettings::all();
+        if ($count < 1 || $count > (int) $settings['simulation_max_members_per_task']) {
+            throw new Exception('一次最多创建 ' . $settings['simulation_max_members_per_task'] . ' 个模拟会员', HttpStatus::CUSTOM_ERROR);
+        }
+        $existing = DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->count();
+        if ($existing + $count > (int) $settings['simulation_max_members_total']) {
+            throw new Exception('模拟会员总数将超过上限 ' . $settings['simulation_max_members_total'], HttpStatus::CUSTOM_ERROR);
+        }
+        $domain = trim((string) ($options['domain'] ?? '')) ?: $settings['simulation_default_domain'];
+        $initial = $options['initial_balance'] ?? $settings['simulation_initial_balance'];
+        $created = [];
+        for ($i = 0; $i < $count; $i++) {
+            $user = DB::transaction(function () use ($domain) {
+                $sn = self::serial();
+                $user = (new User())->forceFill([
+                    'member_id' => $sn, 'user_id' => $sn, 'account' => 'sim_' . $sn, 'first_name' => '模拟_' . substr($sn, -4),
+                    'username' => '', 'from' => '2', 'user_type' => MemberAudience::TYPE_SIMULATED,
+                    'register_ip' => '127.0.0.1', 'register_domain' => $domain, 'last_active_time' => time(),
+                ]);
+                $user->save();
+                (new Wallet())->forceFill(['user_id' => $user->id, 'member_id' => $sn, 'coin' => 'USDT', 'net' => 'TRC20', 'available_balance' => 0])->save();
+                return $user;
+            });
+            MemberAudience::remember($user->member_id);
+            if (is_numeric($initial) && $initial > 0) SimulationFundService::adjust($user->member_id, $initial, 'credit', '模拟会员初始余额');
+            $created[] = ['id' => (int) $user->id, 'member_id' => (string) $user->member_id, 'account' => (string) $user->account];
+        }
+        return $created;
+    }
+
+    /** 8 位 1-9 数字,与前台会员编号同形;纯数字是因为 wallets.member_id 是 bigint。 */
+    private static function serial(): string
+    {
+        for ($try = 0; $try < 50; $try++) {
+            $sn = '';
+            for ($i = 0; $i < 8; $i++) $sn .= (string) random_int(1, 9);
+            if (!DB::table('users')->where('member_id', $sn)->orWhere('user_id', $sn)->exists()) return $sn;
+        }
+        throw new Exception('会员编号生成失败,请重试', HttpStatus::CUSTOM_ERROR);
+    }
+
+    public static function setBanned($memberId, int $banned): void
+    {
+        $user = SimulationSettings::member($memberId);
+        DB::table('users')->where('id', $user->id)->update(['is_banned' => $banned ? 1 : 0]);
+    }
+
+    /** 只写登录日志,不建会话、不签发 token:模拟会员没有可登录前台的凭证。 */
+    public static function login($memberId, string $platform = 'H5', ?string $domain = null): array
+    {
+        SimulationSettings::assertEnabled();
+        $user = SimulationSettings::member($memberId);
+        self::logout($memberId);
+        $row = ['user_id' => (string) $user->user_id, 'login_account' => (string) $user->account, 'login_ip' => '127.0.0.1',
+            'login_domain' => $domain ?: SimulationSettings::get('simulation_default_domain'), 'platform' => $platform, 'status' => UserLogin::STATUS_SUCCESS,
+            'created_at' => now(), 'updated_at' => now()];
+        $row = array_filter($row, fn ($column) => Schema::hasColumn('user_login', $column), ARRAY_FILTER_USE_KEY);
+        $id = DB::table('user_login')->insertGetId($row);
+        $update = ['last_active_time' => time()];
+        if (Schema::hasColumn('users', 'last_login_time')) $update += ['last_login_time' => time(), 'login_count' => DB::raw('COALESCE(login_count, 0) + 1')];
+        DB::table('users')->where('id', $user->id)->update($update);
+        return ['id' => $id, 'member_id' => (string) $user->member_id];
+    }
+
+    public static function logout($memberId): int
+    {
+        $user = SimulationSettings::member($memberId);
+        if (!Schema::hasColumn('user_login', 'logout_time')) return 0;
+        $open = DB::table('user_login')->where('user_id', (string) $user->user_id)->where('status', UserLogin::STATUS_SUCCESS)->whereNull('logout_time')->get(['id', 'created_at']);
+        foreach ($open as $row) {
+            DB::table('user_login')->where('id', $row->id)->update(['logout_time' => now(),
+                'online_duration' => max(0, now()->getTimestamp() - strtotime((string) $row->created_at)), 'updated_at' => now()]);
+        }
+        return $open->count();
+    }
+
+    /** 当前“在线”的模拟会员:有未登出的成功登录记录。 */
+    public static function onlineUserIds()
+    {
+        $query = DB::table('user_login')->where('status', UserLogin::STATUS_SUCCESS)->whereIn('user_id', MemberAudience::simulatedMembers('user_id'));
+        if (Schema::hasColumn('user_login', 'logout_time')) $query->whereNull('logout_time');
+        return $query->distinct()->pluck('user_id');
+    }
+
+    public static function detail($memberId): array
+    {
+        $user = SimulationSettings::member($memberId);
+        $wallet = DB::table('wallets')->where('member_id', $user->member_id)->first();
+        return [
+            'user' => ['id' => $user->id, 'member_id' => $user->member_id, 'account' => $user->account, 'first_name' => $user->first_name,
+                'is_banned' => (int) ($user->is_banned ?? 0), 'register_domain' => $user->register_domain ?? '', 'created_at' => $user->created_at],
+            'wallet' => $wallet ? ['available_balance' => (string) $wallet->available_balance, 'frozen_balance' => (string) $wallet->frozen_balance,
+                'wager_required' => (string) ($wallet->wager_required ?? 0), 'wager_completed' => (string) ($wallet->wager_completed ?? 0)] : null,
+            'logs' => DB::table('balance_logs')->where('member_id', $user->member_id)->orderByDesc('id')->limit(20)->get(),
+        ];
+    }
+}

+ 86 - 0
app/Services/Simulation/SimulationResetService.php

@@ -0,0 +1,86 @@
+<?php
+
+namespace App\Services\Simulation;
+
+use App\Constants\HttpStatus;
+use App\Support\MemberAudience;
+use Exception;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 重置:删除全部后台模拟会员以及他们在业务表、报表投影表里的数据。不可恢复。
+ * 每条删除都以“键列 IN (SELECT … FROM users WHERE user_type = 2)”为条件,不拼接内存里的编号,不清空整表。
+ */
+final class SimulationResetService
+{
+    public const CONFIRM_TEXT = '重置模拟数据';
+    private const TOKEN_KEY = 'simulation_reset_token';
+
+    /** 表 => [表里的会员列, 对应的 users 列] */
+    private const TABLES = [
+        'balance_logs' => ['member_id', 'member_id'], 'payment_orders' => ['member_id', 'member_id'], 'withdraws' => ['member_id', 'member_id'],
+        'recharges' => ['member_id', 'member_id'], 'wallet_freeze_records' => ['member_id', 'member_id'], 'wager_records' => ['member_id', 'member_id'],
+        'lhc_order_item' => ['member_id', 'member_id'], 'lhc_order' => ['member_id', 'member_id'], 'sport_game_order' => ['member_id', 'member_id'],
+        'jisu_game_order' => ['member_id', 'member_id'], 'bets' => ['member_id', 'member_id'], 'backflow' => ['member_id', 'member_id'],
+        'rebates' => ['member_id', 'member_id'], 'activity_user' => ['member_id', 'member_id'],
+        'sport_order' => ['user_id', 'user_id'], 'user_login' => ['user_id', 'user_id'], 'user_session' => ['user_id', 'user_id'], 'yuebao_item' => ['user_id', 'user_id'],
+        'share_bet_facts' => ['user_id', 'id'], 'third_game_orders' => ['user_id', 'id'], 'reporting_orders' => ['user_id', 'id'],
+        'reporting_events' => ['user_id', 'id'], 'reporting_contexts' => ['user_id', 'id'], 'reporting_activity' => ['user_id', 'id'],
+        'member_daily_reports' => ['user_id', 'id'],
+        'wallets' => ['member_id', 'member_id'],
+    ];
+
+    private static function scoped(string $table)
+    {
+        [$column, $userColumn] = self::TABLES[$table];
+        return DB::table($table)->whereIn($column, MemberAudience::simulatedMembers($userColumn));
+    }
+
+    private static function tables(): array
+    {
+        return array_values(array_filter(array_keys(self::TABLES), fn ($table) => Schema::hasTable($table) && Schema::hasColumn($table, self::TABLES[$table][0])));
+    }
+
+    private static function assertSafe(): void
+    {
+        if (!MemberAudience::enabled()) throw new Exception('用户类型字段尚未迁移', HttpStatus::CUSTOM_ERROR);
+        if (DB::table('simulation_task_runs')->whereIn('status', ['queued', 'running', 'paused'])->exists()) {
+            throw new Exception('还有未结束的自动任务,请先停止', HttpStatus::CUSTOM_ERROR);
+        }
+        // 模拟会员一定是 from = 2 且没有代理;出现别的情况说明 user_type 被误标,拒绝批量删除
+        $odd = DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)
+            ->where(fn ($q) => $q->where('from', '<>', '2')->orWhereNull('from')->orWhereNotNull('agent_id'))->count();
+        if ($odd > 0) throw new Exception("有 {$odd} 个标记为模拟的会员不符合模拟会员特征(来源或代理异常),已中止,请人工核查", HttpStatus::CUSTOM_ERROR);
+    }
+
+    /** 预览将删除的行数,并发放一次性确认口令(10 分钟有效)。 */
+    public static function preview(): array
+    {
+        self::assertSafe();
+        $counts = ['users' => DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->count()];
+        foreach (self::tables() as $table) $counts[$table] = self::scoped($table)->count();
+        $counts['simulation_task_runs'] = DB::table('simulation_task_runs')->count();
+        $counts['simulation_task_events'] = DB::table('simulation_task_events')->count();
+        $token = bin2hex(random_bytes(16));
+        Cache::put(self::TOKEN_KEY, $token, 600);
+        return ['counts' => $counts, 'confirm_token' => $token, 'confirm_text' => self::CONFIRM_TEXT];
+    }
+
+    public static function reset(string $token, string $text): array
+    {
+        if ($text !== self::CONFIRM_TEXT) throw new Exception('确认短语不正确', HttpStatus::CUSTOM_ERROR);
+        $expected = Cache::pull(self::TOKEN_KEY);
+        if (!$expected || !hash_equals((string) $expected, $token)) throw new Exception('确认口令已失效,请重新预览', HttpStatus::CUSTOM_ERROR);
+        self::assertSafe();
+        $deleted = [];
+        DB::transaction(function () use (&$deleted) {
+            foreach (self::tables() as $table) $deleted[$table] = self::scoped($table)->delete();
+            $deleted['simulation_task_events'] = DB::table('simulation_task_events')->delete();
+            $deleted['simulation_task_runs'] = DB::table('simulation_task_runs')->delete();
+            $deleted['users'] = DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->where('from', '2')->whereNull('agent_id')->delete();
+        });
+        return $deleted;
+    }
+}

+ 146 - 0
app/Services/Simulation/SimulationRunner.php

@@ -0,0 +1,146 @@
+<?php
+
+namespace App\Services\Simulation;
+
+use App\Models\LhcOrder;
+use Illuminate\Support\Facades\DB;
+use Throwable;
+
+/**
+ * 自动任务执行器,由 simulation:run 命令周期调用。
+ * 每次只推进一个动作并写一条事件;游标保存在运行记录上,暂停、停止、进程重启都能从原处继续,不会重复执行。
+ */
+final class SimulationRunner
+{
+    private const WAIT_SECONDS = 10;      // 等待彩票开奖时的重试间隔
+    private const MAX_WAITS = 180;        // 最多等 30 分钟
+    private const MAX_FAILURE_STREAK = 10; // 连续失败这么多次就把整个运行标为失败
+
+    /** 推进所有到期的运行,返回本次执行的动作数。 */
+    public static function tick(): int
+    {
+        if (!SimulationSettings::enabled()) return 0;
+        $settings = SimulationSettings::all();
+        $slots = (int) $settings['simulation_max_concurrency'] - DB::table('simulation_task_runs')->where('status', 'running')->count();
+        if ($slots > 0) {
+            $queued = DB::table('simulation_task_runs')->where('status', 'queued')->orderBy('id')->limit($slots)->pluck('id');
+            foreach ($queued as $id) {
+                DB::table('simulation_task_runs')->where('id', $id)->where('status', 'queued')
+                    ->update(['status' => 'running', 'started_at' => now(), 'next_run_at' => now(), 'updated_at' => now()]);
+            }
+        }
+        $budget = max(1, intdiv((int) $settings['simulation_rate_per_minute'], 30));
+        $done = 0;
+        $due = DB::table('simulation_task_runs')->where('status', 'running')->where('next_run_at', '<=', now())->orderBy('next_run_at')->pluck('id');
+        foreach ($due as $id) {
+            for ($i = 0; $i < $budget; $i++) {
+                [$acted, $continue] = self::advance((int) $id);
+                $done += $acted ? 1 : 0;
+                if (!$continue) break;
+            }
+        }
+        return $done;
+    }
+
+    /** @return array{0:bool,1:bool} [是否执行了动作, 本轮是否可以继续推进这个运行] */
+    private static function advance(int $id): array
+    {
+        return DB::transaction(function () use ($id) {
+            $run = DB::table('simulation_task_runs')->where('id', $id)->lockForUpdate()->first();
+            if (!$run || $run->status !== 'running' || (string) $run->next_run_at > now()->toDateTimeString()) return [false, false];
+            $steps = json_decode($run->steps, true);
+            $members = json_decode($run->member_ids, true) ?: [];
+            $cursor = json_decode((string) $run->cursor, true) + ['step' => 0, 'member' => 0, 'seq' => 1, 'waited' => 0, 'streak' => 0];
+            if ($cursor['step'] >= count($steps)) {
+                DB::table('simulation_task_runs')->where('id', $id)->update(['status' => 'completed', 'finished_at' => now(), 'updated_at' => now()]);
+                return [false, false];
+            }
+            $step = $steps[$cursor['step']];
+            $memberId = $step['type'] === 'register' ? '' : (string) ($members[$cursor['member']] ?? '');
+            $event = ['run_id' => $id, 'member_id' => $memberId, 'step_index' => $cursor['step'], 'seq' => $cursor['seq'], 'action' => $step['type'],
+                'status' => 'ok', 'ref_type' => null, 'ref_id' => null, 'amount' => $step['amount'] ?? null, 'message' => '', 'created_at' => now()];
+            try {
+                if ($step['type'] !== 'register' && $memberId === '') throw new \RuntimeException('没有可用的模拟会员(注册步骤可能失败)');
+                $result = DB::transaction(fn () => self::perform($step, $memberId));
+                if ($result === null) { // 彩票尚未开奖,稍后再看
+                    if (++$cursor['waited'] <= self::MAX_WAITS) {
+                        DB::table('simulation_task_runs')->where('id', $id)->update(['cursor' => json_encode($cursor),
+                            'next_run_at' => now()->addSeconds(self::WAIT_SECONDS), 'updated_at' => now()]);
+                        return [false, false];
+                    }
+                    throw new \RuntimeException('等待开奖结算超时');
+                }
+                $event = array_merge($event, $result);
+                if ($step['type'] === 'register') {
+                    $members[] = $event['member_id'];
+                    $event['seq'] = $cursor['member'] + 1; // 注册没有会员,用序号保证事件唯一
+                }
+                $cursor['streak'] = 0;
+            } catch (Throwable $e) {
+                $event['status'] = 'failed';
+                $event['message'] = mb_substr($e->getMessage(), 0, 500);
+                if ($step['type'] === 'register') $event['seq'] = $cursor['member'] + 1;
+                $cursor['streak']++;
+            }
+            DB::table('simulation_task_events')->insertOrIgnore($event);
+
+            $cursor['waited'] = 0;
+            if (++$cursor['seq'] > $step['count']) {
+                $cursor['seq'] = 1;
+                if (++$cursor['member'] >= (int) $run->member_count) { $cursor['member'] = 0; $cursor['step']++; }
+            }
+            $update = ['cursor' => json_encode($cursor), 'member_ids' => json_encode($members), 'updated_at' => now(),
+                'next_run_at' => now()->addSeconds((int) $step['interval']),
+                ($event['status'] === 'ok' ? 'completed' : 'failed') => DB::raw(($event['status'] === 'ok' ? 'completed' : 'failed') . ' + 1')];
+            if ($event['status'] === 'failed') $update['last_error'] = $event['message'];
+            $finished = $cursor['step'] >= count($steps);
+            if ($cursor['streak'] >= self::MAX_FAILURE_STREAK) $update += ['status' => 'failed', 'finished_at' => now()];
+            elseif ($finished) $update += ['status' => 'completed', 'finished_at' => now()];
+            DB::table('simulation_task_runs')->where('id', $id)->update($update);
+            return [true, !isset($update['status']) && (int) $step['interval'] === 0];
+        });
+    }
+
+    /** 执行一个动作。返回要写进事件的字段;返回 null 表示还要等(彩票未开奖)。 */
+    private static function perform(array $step, string $memberId): ?array
+    {
+        switch ($step['type']) {
+            case 'register':
+                $created = SimulationMemberService::create(1)[0];
+                return ['member_id' => $created['member_id'], 'ref_type' => 'users', 'ref_id' => (string) $created['id'], 'message' => '注册 ' . $created['account']];
+            case 'login':
+                $login = SimulationMemberService::login($memberId, $step['platform'] ?? 'H5');
+                return ['ref_type' => 'user_login', 'ref_id' => (string) $login['id'], 'message' => '登录成功'];
+            case 'logout':
+                return ['message' => '退出 ' . SimulationMemberService::logout($memberId) . ' 个会话'];
+            case 'recharge':
+                $order = SimulationFundService::recharge($memberId, $step['amount'], 'third', 'success');
+                return ['ref_type' => 'payment_orders', 'ref_id' => (string) $order->id, 'message' => '充值成功'];
+            case 'withdraw':
+                $order = SimulationFundService::withdrawApply($memberId, $step['amount']);
+                SimulationFundService::withdrawAudit((int) $order->id, true);
+                return ['ref_type' => 'payment_orders', 'ref_id' => (string) $order->id, 'message' => '提现成功'];
+            case 'bet':
+                if (($step['category'] ?? 'lottery') === 'sport') {
+                    $order = SimulationBetService::sport($memberId, $step['amount'], number_format(mt_rand(150, 250) / 100, 2, '.', ''));
+                    return ['ref_type' => 'sport_order', 'ref_id' => (string) $order->id, 'message' => '体育下注 赔率 ' . (float) $order->odd];
+                }
+                $keywords = ['大', '小', '单', '双'][mt_rand(0, 3)];
+                $order = SimulationBetService::lottery($memberId, $keywords, $step['amount']);
+                return ['ref_type' => 'lhc_order', 'ref_id' => (string) $order->id, 'message' => "彩票下注 {$order->issue} 期 {$keywords}"];
+            case 'settle':
+                $pending = LhcOrder::where('member_id', $memberId)->where('bet_source', LhcOrder::SOURCE_SIMULATION)
+                    ->where('lottery_status', LhcOrder::STATUS_STAY)->count();
+                $user = SimulationSettings::member($memberId);
+                $sport = SimulationBetService::sportQuery()->where('sport_order.user_id', (string) $user->user_id)
+                    ->where('sport_order.settlement_status', 0)->where('sport_order.status', 1)->pluck('sport_order.id');
+                foreach ($sport as $orderId) {
+                    $mode = $step['settle_mode'] ?? 'random';
+                    SimulationBetService::settleSport((int) $orderId, $mode === 'random' ? ['win', 'lose', 'draw', 'half'][mt_rand(0, 3)] : $mode);
+                }
+                if ($pending > 0) return null;
+                return ['amount' => null, 'message' => '体育结算 ' . count($sport) . ' 单,彩票已全部开奖'];
+        }
+        throw new \RuntimeException('未知步骤:' . $step['type']);
+    }
+}

+ 82 - 0
app/Services/Simulation/SimulationSettings.php

@@ -0,0 +1,82 @@
+<?php
+
+namespace App\Services\Simulation;
+
+use App\Constants\HttpStatus;
+use App\Support\MemberAudience;
+use Exception;
+use Illuminate\Support\Facades\DB;
+
+/** 模拟管理的设置(config 表 simulation_* 键)以及每个写操作都要过的两道检查。 */
+final class SimulationSettings
+{
+    /** 键 => [默认值, 最小, 最大];最小为 null 的是字符串。 */
+    public const KEYS = [
+        'simulation_enabled' => ['0', 0, 1],
+        'simulation_max_concurrency' => ['2', 1, 10],
+        'simulation_rate_per_minute' => ['30', 1, 120],
+        'simulation_max_members_per_task' => ['20', 1, 200],
+        'simulation_max_members_total' => ['500', 1, 5000],
+        'simulation_max_amount' => ['10000', 1, 1000000],
+        'simulation_initial_balance' => ['0', 0, 1000000],
+        'simulation_wager_bypass' => ['1', 0, 1],
+        'simulation_default_domain' => ['sim.local', null, null],
+    ];
+
+    public static function all(): array
+    {
+        $stored = DB::table('config')->whereIn('field', array_keys(self::KEYS))->pluck('val', 'field');
+        $out = [];
+        foreach (self::KEYS as $key => [$default]) $out[$key] = (string) ($stored[$key] ?? $default);
+        return $out;
+    }
+
+    public static function get(string $key): string
+    {
+        return self::all()[$key];
+    }
+
+    public static function save(array $input): array
+    {
+        $before = self::all();
+        foreach (self::KEYS as $key => [, $min, $max]) {
+            if (!array_key_exists($key, $input)) continue;
+            $value = trim((string) $input[$key]);
+            if ($min === null) {
+                if ($value === '' || strlen($value) > 100) throw new Exception('域名格式不正确', HttpStatus::CUSTOM_ERROR);
+            } elseif (!is_numeric($value) || $value < $min || $value > $max) {
+                throw new Exception("{$key} 应在 {$min} 到 {$max} 之间", HttpStatus::CUSTOM_ERROR);
+            }
+            $exists = DB::table('config')->where('field', $key)->exists();
+            if ($exists) DB::table('config')->where('field', $key)->update(['val' => $value]);
+            else DB::table('config')->insert(['field' => $key, 'val' => $value]);
+        }
+        return ['before' => $before, 'after' => self::all()];
+    }
+
+    public static function enabled(): bool
+    {
+        return self::get('simulation_enabled') === '1';
+    }
+
+    public static function assertEnabled(): void
+    {
+        if (!MemberAudience::enabled()) throw new Exception('用户类型字段尚未迁移,无法使用模拟管理', HttpStatus::CUSTOM_ERROR);
+        if (!self::enabled()) throw new Exception('模拟功能未开启,请先在“模拟设置”中开启', HttpStatus::CUSTOM_ERROR);
+    }
+
+    /** 写操作的目标必须是后台模拟会员,返回该会员。 */
+    public static function member($memberId): object
+    {
+        $user = DB::table('users')->where('member_id', (string) $memberId)->where('user_type', MemberAudience::TYPE_SIMULATED)->first();
+        if (!$user) throw new Exception('只能操作模拟会员', HttpStatus::CUSTOM_ERROR);
+        return $user;
+    }
+
+    public static function assertAmount($amount): string
+    {
+        if (!is_numeric($amount) || $amount <= 0) throw new Exception('金额格式不正确', HttpStatus::CUSTOM_ERROR);
+        if ($amount > (float) self::get('simulation_max_amount')) throw new Exception('金额超过模拟设置的单次上限', HttpStatus::CUSTOM_ERROR);
+        return number_format((float) $amount, 2, '.', '');
+    }
+}

+ 116 - 0
app/Services/Simulation/SimulationTaskService.php

@@ -0,0 +1,116 @@
+<?php
+
+namespace App\Services\Simulation;
+
+use App\Constants\HttpStatus;
+use App\Support\MemberAudience;
+use Exception;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 自动任务:模板(simulation_tasks)和运行(simulation_task_runs)。
+ * 一次运行按步骤顺序执行,每一步对每个模拟会员做 count 次;实际执行在 SimulationRunner。
+ */
+final class SimulationTaskService
+{
+    public const STEP_TYPES = ['register', 'login', 'recharge', 'bet', 'settle', 'withdraw', 'logout'];
+    /** 每个会员只做一次的步骤,count 不生效。 */
+    public const ONCE = ['register', 'login', 'logout', 'settle'];
+
+    public static function normalizeSteps($steps): array
+    {
+        if (!is_array($steps) || $steps === []) throw new Exception('请至少添加一个任务步骤', HttpStatus::CUSTOM_ERROR);
+        if (count($steps) > 20) throw new Exception('任务步骤不能超过 20 个', HttpStatus::CUSTOM_ERROR);
+        $out = [];
+        foreach ($steps as $step) {
+            $type = (string) ($step['type'] ?? '');
+            if (!in_array($type, self::STEP_TYPES, true)) throw new Exception('任务步骤类型不正确:' . $type, HttpStatus::CUSTOM_ERROR);
+            $row = ['type' => $type, 'count' => in_array($type, self::ONCE, true) ? 1 : min(50, max(1, (int) ($step['count'] ?? 1))),
+                'interval' => min(3600, max(0, (int) ($step['interval'] ?? 2)))];
+            if (in_array($type, ['recharge', 'bet', 'withdraw'], true)) $row['amount'] = SimulationSettings::assertAmount($step['amount'] ?? 100);
+            if ($type === 'bet') $row['category'] = ($step['category'] ?? 'lottery') === 'sport' ? 'sport' : 'lottery';
+            if ($type === 'settle') $row['settle_mode'] = in_array($step['settle_mode'] ?? 'random', ['random', 'win', 'lose'], true) ? ($step['settle_mode'] ?? 'random') : 'random';
+            if (in_array($type, ['register', 'login'], true)) $row['platform'] = substr((string) ($step['platform'] ?? 'H5'), 0, 16);
+            $out[] = $row;
+        }
+        return $out;
+    }
+
+    public static function saveTemplate(array $input, ?int $adminId): int
+    {
+        $name = trim((string) ($input['name'] ?? ''));
+        if ($name === '' || mb_strlen($name) > 100) throw new Exception('请填写模板名称(不超过 100 字)', HttpStatus::CUSTOM_ERROR);
+        $row = ['name' => $name, 'steps' => json_encode(self::normalizeSteps($input['steps'] ?? null), JSON_UNESCAPED_UNICODE),
+            'target_mode' => ($input['target_mode'] ?? 'new') === 'existing' ? 'existing' : 'new',
+            'member_count' => self::memberCount($input['member_count'] ?? 1), 'remark' => mb_substr((string) ($input['remark'] ?? ''), 0, 255), 'updated_at' => now()];
+        if (!empty($input['id'])) {
+            if (!DB::table('simulation_tasks')->where('id', (int) $input['id'])->update($row)) throw new Exception('模板不存在', HttpStatus::CUSTOM_ERROR);
+            return (int) $input['id'];
+        }
+        return (int) DB::table('simulation_tasks')->insertGetId($row + ['created_by' => $adminId, 'created_at' => now()]);
+    }
+
+    private static function memberCount($count): int
+    {
+        $count = (int) $count;
+        $max = (int) SimulationSettings::get('simulation_max_members_per_task');
+        if ($count < 1 || $count > $max) throw new Exception("任务会员数应在 1 到 {$max} 之间", HttpStatus::CUSTOM_ERROR);
+        return $count;
+    }
+
+    /** 新建一次运行并排队。来自模板(task_id)或直接给步骤;existing 模式要给 member_ids。 */
+    public static function start(array $input, ?int $adminId): int
+    {
+        SimulationSettings::assertEnabled();
+        $task = !empty($input['task_id']) ? DB::table('simulation_tasks')->where('id', (int) $input['task_id'])->first() : null;
+        if (!empty($input['task_id']) && !$task) throw new Exception('模板不存在', HttpStatus::CUSTOM_ERROR);
+        $steps = self::normalizeSteps($input['steps'] ?? ($task ? json_decode($task->steps, true) : null));
+        $memberIds = array_values(array_unique(array_map('strval', (array) ($input['member_ids'] ?? []))));
+        $registers = in_array('register', array_column($steps, 'type'), true);
+        if ($registers) {
+            if ($steps[0]['type'] !== 'register' || count(array_keys(array_column($steps, 'type'), 'register')) > 1) {
+                throw new Exception('“注册”只能作为任务的第一个步骤,且只能有一个', HttpStatus::CUSTOM_ERROR);
+            }
+            $memberIds = [];
+            $count = self::memberCount($input['member_count'] ?? ($task->member_count ?? 1));
+        } else {
+            if ($memberIds === []) throw new Exception('任务没有“注册”步骤时,请选择要使用的模拟会员', HttpStatus::CUSTOM_ERROR);
+            $count = self::memberCount(count($memberIds));
+            $valid = DB::table('users')->whereIn('member_id', $memberIds)->where('user_type', MemberAudience::TYPE_SIMULATED)->count();
+            if ($valid !== count($memberIds)) throw new Exception('所选会员中包含非模拟会员', HttpStatus::CUSTOM_ERROR);
+        }
+        $total = 0;
+        foreach ($steps as $step) $total += $step['count'] * $count;
+        $name = trim((string) ($input['name'] ?? '')) ?: ($task->name ?? '临时任务');
+        return (int) DB::table('simulation_task_runs')->insertGetId(['task_id' => $task->id ?? null, 'name' => mb_substr($name, 0, 100),
+            'steps' => json_encode($steps, JSON_UNESCAPED_UNICODE), 'member_ids' => json_encode($memberIds), 'member_count' => $count,
+            'status' => 'queued', 'total' => $total, 'cursor' => json_encode(['step' => 0, 'member' => 0, 'seq' => 1, 'waited' => 0]),
+            'next_run_at' => now(), 'created_by' => $adminId, 'created_at' => now(), 'updated_at' => now()]);
+    }
+
+    /** 暂停 / 继续 / 停止;下一次调度时生效。 */
+    public static function setStatus(int $id, string $action): object
+    {
+        $run = DB::table('simulation_task_runs')->where('id', $id)->first();
+        if (!$run) throw new Exception('运行记录不存在', HttpStatus::CUSTOM_ERROR);
+        $next = match (true) {
+            $action === 'pause' && in_array($run->status, ['queued', 'running'], true) => 'paused',
+            $action === 'resume' && $run->status === 'paused' => 'running',
+            $action === 'stop' && in_array($run->status, ['queued', 'running', 'paused'], true) => 'stopped',
+            default => throw new Exception('当前状态不能执行该操作', HttpStatus::CUSTOM_ERROR),
+        };
+        $update = ['status' => $next, 'updated_at' => now()];
+        if ($next === 'running') { SimulationSettings::assertEnabled(); $update['next_run_at'] = now(); }
+        if ($next === 'stopped') $update['finished_at'] = now();
+        DB::table('simulation_task_runs')->where('id', $id)->where('status', $run->status)->update($update);
+        return DB::table('simulation_task_runs')->where('id', $id)->first();
+    }
+
+    public static function present(object $run): array
+    {
+        $row = (array) $run;
+        foreach (['steps', 'member_ids', 'cursor'] as $key) $row[$key] = json_decode((string) ($row[$key] ?? 'null'), true);
+        $row['progress'] = $run->total > 0 ? (int) floor(($run->completed + $run->failed) * 100 / $run->total) : 0;
+        return $row;
+    }
+}

+ 1 - 0
app/Services/ThirdGameOrderService.php

@@ -279,6 +279,7 @@ class ThirdGameOrderService
         $limit = min(200, max(1, (int) ($params['limit'] ?? 20)));
         $limit = min(200, max(1, (int) ($params['limit'] ?? 20)));
         $query = ThirdGameOrderModel::query()
         $query = ThirdGameOrderModel::query()
             ->leftJoin('users', 'users.id', '=', 'third_game_orders.user_id');
             ->leftJoin('users', 'users.id', '=', 'third_game_orders.user_id');
+        \App\Support\MemberAudience::visibleMembers($query, 'third_game_orders.user_id', 'id'); // 后台模拟会员只在模拟管理里出现
         if ($this->provider->playerPrefix() === 'tu') $query->where('third_game_orders.player_id', 'like', 'tu%');
         if ($this->provider->playerPrefix() === 'tu') $query->where('third_game_orders.player_id', 'like', 'tu%');
         else $query->where('third_game_orders.player_id', 'not like', 'tu%');
         else $query->where('third_game_orders.player_id', 'not like', 'tu%');
 
 

+ 2 - 2
app/Services/ThirdPartyDepositService.php

@@ -97,8 +97,8 @@ class ThirdPartyDepositService
 
 
     private static function query(array $params = [], bool $withRawData = false): Builder
     private static function query(array $params = [], bool $withRawData = false): Builder
     {
     {
-        $query = PaymentOrder::query()
-            ->join('users', 'users.member_id', '=', 'payment_orders.member_id')
+        $query = \App\Support\MemberAudience::visibleUsers(PaymentOrder::query()
+            ->join('users', 'users.member_id', '=', 'payment_orders.member_id'), 'users') // 后台模拟会员只在模拟管理里出现
             ->where('payment_orders.type', PaymentOrderService::TYPE_PAY)
             ->where('payment_orders.type', PaymentOrderService::TYPE_PAY)
             ->select([
             ->select([
                 'payment_orders.id',
                 'payment_orders.id',

+ 1 - 0
app/Services/UserService.php

@@ -146,6 +146,7 @@ class UserService extends BaseService
         $limit = isset($search['limit']) ? $search['limit'] : 15;
         $limit = isset($search['limit']) ? $search['limit'] : 15;
         $page = isset($search['page']) ? $search['page'] : 1;
         $page = isset($search['page']) ? $search['page'] : 1;
         $query = static::$MODEL::where(self::getWhere($search));
         $query = static::$MODEL::where(self::getWhere($search));
+        \App\Support\MemberAudience::visibleUsers($query, 'users'); // 后台模拟会员只在模拟管理里出现
         [$onlineSql, $onlineBindings] = OnlineUserService::onlineStatusSql($query->getConnection()->getTablePrefix() . 'users');
         [$onlineSql, $onlineBindings] = OnlineUserService::onlineStatusSql($query->getConnection()->getTablePrefix() . 'users');
         if (isset($search['is_online'])) {
         if (isset($search['is_online'])) {
             $query->whereRaw("{$onlineSql} = ?", [...$onlineBindings, (int) $search['is_online']]);
             $query->whereRaw("{$onlineSql} = ?", [...$onlineBindings, (int) $search['is_online']]);

+ 1 - 0
app/Services/WalletFreezeService.php

@@ -110,6 +110,7 @@ class WalletFreezeService
         $page = max(1, (int)($params['page'] ?? 1));
         $page = max(1, (int)($params['page'] ?? 1));
         $limit = min(200, max(1, (int)($params['limit'] ?? 20)));
         $limit = min(200, max(1, (int)($params['limit'] ?? 20)));
         $query = Record::query()->with('member');
         $query = Record::query()->with('member');
+        \App\Support\MemberAudience::visibleMembers($query, 'member_id'); // 后台模拟会员只在模拟管理里出现
         if (!empty($params['member_id'])) $query->where('member_id', (string)$params['member_id']);
         if (!empty($params['member_id'])) $query->where('member_id', (string)$params['member_id']);
         if (!empty($params['account'])) {
         if (!empty($params['account'])) {
             $query->whereIn('member_id', User::select('member_id')->where('account', 'like', '%' . trim((string)$params['account']) . '%'));
             $query->whereIn('member_id', User::select('member_id')->where('account', 'like', '%' . trim((string)$params['account']) . '%'));

+ 2 - 1
app/Services/WithdrawService.php

@@ -693,7 +693,8 @@ class WithdrawService
         $text .= "提现地址:{$w->address}\n\n";
         $text .= "提现地址:{$w->address}\n\n";
         $text .= "状态:{$arr[$w->status]}\n";
         $text .= "状态:{$arr[$w->status]}\n";
         if ($w->remark) $text .= "说明:{$w->remark}";
         if ($w->remark) $text .= "说明:{$w->remark}";
-        WithdrawService::notify([
+        // 后台模拟会员没有 Telegram 账号,不通知
+        if (!\App\Support\MemberAudience::isSimulatedMember($w->member_id)) WithdrawService::notify([
             'chat_id' => $w->member_id,
             'chat_id' => $w->member_id,
             'text' => $text,
             'text' => $text,
         ]);
         ]);

+ 99 - 0
app/Support/MemberAudience.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace App\Support;
+
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 会员受众:区分正式数据和模拟数据(users.user_type = 2 的后台模拟会员)。
+ * 默认 REAL:所有列表和统计都看不到模拟会员;SIMULATED:只看模拟会员,仅 /admin/simulation/* 通过 simulated() 进入。
+ * 游客(users.from = 2 且不是模拟会员)是否参与由调用方决定,这里只管模拟会员。
+ */
+final class MemberAudience
+{
+    public const REAL = 'real';
+    public const SIMULATED = 'simulated';
+    public const TYPE_SIMULATED = 2;
+
+    private static string $mode = self::REAL;
+    private static ?\WeakMap $enabled = null;
+    private static ?\WeakMap $memo = null;
+
+    public static function isSimulated(): bool
+    {
+        return self::$mode === self::SIMULATED;
+    }
+
+    /** 在“仅模拟会员”口径下执行 $fn,结束后一定还原。 */
+    public static function simulated(callable $fn)
+    {
+        $previous = self::$mode;
+        self::$mode = self::SIMULATED;
+        try {
+            return $fn();
+        } finally {
+            self::$mode = $previous;
+        }
+    }
+
+    /** users.user_type 存在才区分(迁移执行前不过滤)。按数据库连接缓存。 */
+    public static function enabled(): bool
+    {
+        self::$enabled ??= new \WeakMap();
+        $connection = DB::connection();
+        return self::$enabled[$connection] ??= DB::getSchemaBuilder()->hasColumn('users', 'user_type');
+    }
+
+    /** 模拟会员的某一列(member_id / user_id / id)子查询。 */
+    public static function simulatedMembers(string $column = 'member_id')
+    {
+        return DB::table('users')->select($column)->where('user_type', self::TYPE_SIMULATED)->whereNotNull($column);
+    }
+
+    /** 查询里已有 users 表(或别名)时:REAL 排除模拟会员,SIMULATED 只留模拟会员。 */
+    public static function visibleUsers($query, string $alias = 'users')
+    {
+        if (!self::enabled()) return self::isSimulated() ? $query->whereRaw('1 = 0') : $query;
+        return $query->where($alias . '.user_type', self::isSimulated() ? '=' : '<>', self::TYPE_SIMULATED);
+    }
+
+    /** 查询里没有 users 表时,按会员列过滤。$userColumn 是 $column 对应的 users 列。$column 为空的行在 REAL 口径下保留。 */
+    public static function visibleMembers($query, string $column = 'member_id', string $userColumn = 'member_id')
+    {
+        if (!self::enabled()) return self::isSimulated() ? $query->whereRaw('1 = 0') : $query;
+        if (self::isSimulated()) return $query->whereIn($column, self::simulatedMembers($userColumn));
+        return $query->where(fn ($q) => $q->whereNull($column)->orWhereNotIn($column, self::simulatedMembers($userColumn)));
+    }
+
+    /** 原生 SQL 用:users 别名为 $alias 时追加的条件(以 AND 开头)。 */
+    public static function rawVisibleUsers(string $alias = 'u'): string
+    {
+        if (!self::enabled()) return self::isSimulated() ? ' AND 1 = 0' : '';
+        return " AND {$alias}.user_type " . (self::isSimulated() ? '=' : '<>') . ' ' . self::TYPE_SIMULATED;
+    }
+
+    /** 某个 member_id 是否是模拟会员(同一连接内缓存;用户类型创建后不会变)。 */
+    public static function isSimulatedMember($memberId): bool
+    {
+        if ($memberId === null || $memberId === '' || !self::enabled()) return false;
+        self::$memo ??= new \WeakMap();
+        $connection = DB::connection();
+        $known = self::$memo[$connection] ?? [];
+        $key = (string) $memberId;
+        if (!isset($known[$key])) {
+            $known[$key] = DB::table('users')->where('member_id', $key)->where('user_type', self::TYPE_SIMULATED)->exists();
+            self::$memo[$connection] = $known;
+        }
+        return $known[$key];
+    }
+
+    /** 新建模拟会员后登记,免得同一请求里缓存了“不是模拟会员”。 */
+    public static function remember($memberId): void
+    {
+        self::$memo ??= new \WeakMap();
+        $connection = DB::connection();
+        $known = self::$memo[$connection] ?? [];
+        $known[(string) $memberId] = true;
+        self::$memo[$connection] = $known;
+    }
+}

+ 34 - 9
app/Support/StatsScope.php

@@ -5,7 +5,8 @@ namespace App\Support;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
 
 
 /**
 /**
- * 统计口径:排除试玩账号(users.from = 2)和测试会员 / 测试代理(agents.is_test = 1)。
+ * 统计口径:排除游客(users.from = 2)、后台模拟会员(users.user_type = 2)和测试会员 / 测试代理(agents.is_test = 1)。
+ * 模拟口径(MemberAudience::simulated())下反过来:只统计模拟会员,代理资金一律不计。
  * 测试会员 = users.agent_id 指向测试代理的会员,按代理当前设置判断(切换后历史数据跟着变)。
  * 测试会员 = users.agent_id 指向测试代理的会员,按代理当前设置判断(切换后历史数据跟着变)。
  * 只用于统计;明细列表、待办角标、代理看自己名下会员的数据不用它。
  * 只用于统计;明细列表、待办角标、代理看自己名下会员的数据不用它。
  */
  */
@@ -36,34 +37,57 @@ final class StatsScope
     /** 测试会员的某一列(member_id / id)子查询。 */
     /** 测试会员的某一列(member_id / id)子查询。 */
     public static function testMembers(string $column = 'member_id')
     public static function testMembers(string $column = 'member_id')
     {
     {
-        return DB::table('users')->select($column)->whereIn('agent_id', self::testAgentIds());
+        return DB::table('users')->select($column)->whereNotNull($column)->whereIn('agent_id', self::testAgentIds());
     }
     }
 
 
-    /** users 查询:排除测试会员。$alias 为 users 表在查询里的别名或表名。 */
+    /** 不进正式统计的会员的某一列子查询:模拟会员、游客(from = 2)、测试会员。 */
+    private static function excludedMembers(string $column)
+    {
+        $testAgents = self::enabled();
+        return DB::table('users')->select($column)->whereNotNull($column)->where(function ($q) use ($testAgents) {
+            $q->where('user_type', '<>', 0)->orWhere('from', 2);
+            if ($testAgents) $q->orWhereIn('agent_id', self::testAgentIds());
+        });
+    }
+
+    /** users 查询:排除测试会员和模拟会员(游客保留,由调用方按 from 区分)。$alias 为 users 表在查询里的别名或表名。 */
     public static function excludeTestUsers($query, string $alias = 'users')
     public static function excludeTestUsers($query, string $alias = 'users')
     {
     {
-        if (!self::enabled()) return $query;
+        MemberAudience::visibleUsers($query, $alias);
+        if (MemberAudience::isSimulated() || !self::enabled()) return $query;
         return $query->where(fn ($q) => $q->whereNull($alias . '.agent_id')->orWhereNotIn($alias . '.agent_id', self::testAgentIds()));
         return $query->where(fn ($q) => $q->whereNull($alias . '.agent_id')->orWhereNotIn($alias . '.agent_id', self::testAgentIds()));
     }
     }
 
 
-    /** users 查询:只留真实会员(排除试玩账号和测试会员)。 */
+    /** users 查询:只留真实会员(排除游客、模拟会员和测试会员)。模拟口径下只留模拟会员。 */
     public static function realUsers($query, string $alias = 'users')
     public static function realUsers($query, string $alias = 'users')
     {
     {
+        if (MemberAudience::isSimulated()) return MemberAudience::visibleUsers($query, $alias);
         $query->where(fn ($q) => $q->whereNull($alias . '.from')->orWhere($alias . '.from', '<>', 2));
         $query->where(fn ($q) => $q->whereNull($alias . '.from')->orWhere($alias . '.from', '<>', 2));
         return self::excludeTestUsers($query, $alias);
         return self::excludeTestUsers($query, $alias);
     }
     }
 
 
-    /** 原生 SQL 用:users 别名为 $alias 时追加的条件(以 AND 开头),未启用时为空串。 */
+    /** users 查询:只留游客(from = 2 且不是模拟会员)。 */
+    public static function guestUsers($query, string $alias = 'users')
+    {
+        if (MemberAudience::isSimulated()) return $query->whereRaw('1 = 0');
+        return MemberAudience::visibleUsers($query->where($alias . '.from', 2), $alias);
+    }
+
+    /** 原生 SQL 用:users 别名为 $alias 时追加的条件(以 AND 开头),只留真实会员;模拟口径下只留模拟会员。 */
     public static function rawExcludeTestUsers(string $alias = 'u'): string
     public static function rawExcludeTestUsers(string $alias = 'u'): string
     {
     {
-        if (!self::enabled()) return '';
+        if (MemberAudience::isSimulated()) return MemberAudience::rawVisibleUsers($alias);
+        $sql = MemberAudience::enabled() ? " AND {$alias}.user_type = 0 AND ({$alias}.`from` IS NULL OR {$alias}.`from` <> '2')" : '';
+        if (!self::enabled()) return $sql;
         $agents = DB::getTablePrefix() . 'agents';
         $agents = DB::getTablePrefix() . 'agents';
-        return " AND ({$alias}.agent_id IS NULL OR {$alias}.agent_id NOT IN (SELECT id FROM {$agents} WHERE is_test = 1))";
+        return $sql . " AND ({$alias}.agent_id IS NULL OR {$alias}.agent_id NOT IN (SELECT id FROM {$agents} WHERE is_test = 1))";
     }
     }
 
 
-    /** 按会员统计的表(member_id 或 user_id 列):排除测试会员。 */
+    /** 按会员统计的表(member_id 或 user_id 列):只留真实会员的数据;模拟口径下只留模拟会员的数据。 */
     public static function excludeTestMembers($query, string $column = 'member_id', string $userColumn = 'member_id')
     public static function excludeTestMembers($query, string $column = 'member_id', string $userColumn = 'member_id')
     {
     {
+        if (MemberAudience::isSimulated()) return MemberAudience::visibleMembers($query, $column, $userColumn);
+        if (MemberAudience::enabled()) return $query->whereNotIn($column, self::excludedMembers($userColumn));
         if (!self::enabled()) return $query;
         if (!self::enabled()) return $query;
         return $query->whereNotIn($column, self::testMembers($userColumn));
         return $query->whereNotIn($column, self::testMembers($userColumn));
     }
     }
@@ -71,6 +95,7 @@ final class StatsScope
     /** 代理资金表(agent_id 列):排除测试代理。agent_id 为空的行保留。 */
     /** 代理资金表(agent_id 列):排除测试代理。agent_id 为空的行保留。 */
     public static function excludeTestAgents($query, string $column = 'agent_id')
     public static function excludeTestAgents($query, string $column = 'agent_id')
     {
     {
+        if (MemberAudience::isSimulated()) return $query->whereRaw('1 = 0');
         if (!self::enabled()) return $query;
         if (!self::enabled()) return $query;
         return $query->where(fn ($q) => $q->whereNull($column)->orWhereNotIn($column, self::testAgentIds()));
         return $query->where(fn ($q) => $q->whereNull($column)->orWhereNotIn($column, self::testAgentIds()));
     }
     }

+ 38 - 0
database/migrations/2026_11_11_120000_add_user_type_to_users.php

@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 用户类型:0 正常会员 / 1 游客试玩(原 from = 2)/ 2 后台模拟会员。
+ * 模拟会员只在“模拟管理”里出现,其他列表和统计一律排除,见 App\Support\MemberAudience、App\Support\StatsScope。
+ */
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasColumn('users', 'user_type')) {
+            Schema::table('users', function (Blueprint $t) {
+                $t->unsignedTinyInteger('user_type')->default(0)->comment('0 正常 1 游客试玩 2 后台模拟');
+                $t->index(['user_type', 'member_id'], 'users_user_type_member_idx');
+            });
+        }
+        // 现有游客回填为 1,按主键分批,避免长时间锁表
+        $max = (int) DB::table('users')->max('id');
+        for ($from = 0; $from <= $max; $from += 5000) {
+            DB::table('users')->where('id', '>', $from)->where('id', '<=', $from + 5000)
+                ->where('from', '2')->where('user_type', 0)->update(['user_type' => 1]);
+        }
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasColumn('users', 'user_type')) {
+            Schema::table('users', function (Blueprint $t) {
+                $t->dropIndex('users_user_type_member_idx');
+                $t->dropColumn('user_type');
+            });
+        }
+    }
+};

+ 97 - 0
database/migrations/2026_11_11_130000_create_simulation_tables.php

@@ -0,0 +1,97 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/** 模拟管理:自动任务的模板、运行、事件三张表,以及 config 表里的模拟设置默认值(默认关闭)。 */
+return new class extends Migration {
+    private const SETTINGS = [
+        'simulation_enabled' => ['0', '模拟管理总开关:1 开启'],
+        'simulation_max_concurrency' => ['2', '模拟自动任务同时运行数上限'],
+        'simulation_rate_per_minute' => ['30', '模拟自动任务每分钟动作数上限'],
+        'simulation_max_members_per_task' => ['20', '单次创建/单个任务的模拟会员数上限'],
+        'simulation_max_members_total' => ['500', '模拟会员总数上限'],
+        'simulation_max_amount' => ['10000', '模拟充值/提现/下注单次金额上限'],
+        'simulation_initial_balance' => ['0', '新建模拟会员的初始余额'],
+        'simulation_wager_bypass' => ['1', '模拟提现是否跳过打码量:1 跳过'],
+        'simulation_default_domain' => ['sim.local', '模拟会员默认注册/登录域名'],
+    ];
+
+    public function up(): void
+    {
+        if (!Schema::hasTable('simulation_tasks')) {
+            Schema::create('simulation_tasks', function (Blueprint $t) {
+                $t->id();
+                $t->string('name', 100);
+                $t->json('steps');
+                $t->string('target_mode', 16)->default('new')->comment('new 新建会员 / existing 使用已有模拟会员');
+                $t->unsignedInteger('member_count')->default(1);
+                $t->string('remark', 255)->default('');
+                $t->unsignedBigInteger('created_by')->nullable();
+                $t->timestamps();
+            });
+        }
+        if (!Schema::hasTable('simulation_task_runs')) {
+            Schema::create('simulation_task_runs', function (Blueprint $t) {
+                $t->id();
+                $t->unsignedBigInteger('task_id')->nullable();
+                $t->string('name', 100);
+                $t->json('steps');
+                $t->json('member_ids');
+                $t->unsignedInteger('member_count')->default(1);
+                $t->string('status', 16)->default('queued')->comment('queued running paused stopped completed failed');
+                $t->unsignedInteger('total')->default(0);
+                $t->unsignedInteger('completed')->default(0);
+                $t->unsignedInteger('failed')->default(0);
+                $t->json('cursor')->nullable();
+                $t->dateTime('next_run_at')->nullable();
+                $t->dateTime('started_at')->nullable();
+                $t->dateTime('finished_at')->nullable();
+                $t->unsignedBigInteger('created_by')->nullable();
+                $t->string('last_error', 500)->nullable();
+                $t->timestamps();
+                $t->index(['status', 'next_run_at']);
+            });
+        }
+        if (!Schema::hasTable('simulation_task_events')) {
+            Schema::create('simulation_task_events', function (Blueprint $t) {
+                $t->id();
+                $t->unsignedBigInteger('run_id');
+                $t->string('member_id', 64)->default('');
+                $t->unsignedInteger('step_index');
+                $t->unsignedInteger('seq');
+                $t->string('action', 16);
+                $t->string('status', 16)->comment('ok failed');
+                $t->string('ref_type', 32)->nullable();
+                $t->string('ref_id', 64)->nullable();
+                $t->decimal('amount', 22, 2)->nullable();
+                $t->string('message', 500)->default('');
+                $t->dateTime('created_at');
+                $t->unique(['run_id', 'step_index', 'member_id', 'seq'], 'simulation_task_events_step_unique');
+            });
+        }
+        if (Schema::hasTable('config')) {
+            $hasRemark = Schema::hasColumn('config', 'remark');
+            $hasGroup = Schema::hasColumn('config', 'group_id');
+            $hasTimes = Schema::hasColumn('config', 'created_at');
+            foreach (self::SETTINGS as $field => [$value, $remark]) {
+                if (DB::table('config')->where('field', $field)->exists()) continue;
+                $row = ['field' => $field, 'val' => $value];
+                if ($hasRemark) $row['remark'] = $remark;
+                if ($hasGroup) $row['group_id'] = 1;
+                if ($hasTimes) $row += ['created_at' => now(), 'updated_at' => now()];
+                DB::table('config')->insert($row);
+            }
+        }
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('simulation_task_events');
+        Schema::dropIfExists('simulation_task_runs');
+        Schema::dropIfExists('simulation_tasks');
+        if (Schema::hasTable('config')) DB::table('config')->whereIn('field', array_keys(self::SETTINGS))->delete();
+    }
+};

+ 66 - 0
database/migrations/2026_11_11_140000_add_simulation_admin_menus.php

@@ -0,0 +1,66 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 模拟管理菜单及按钮权限。菜单 uri 等于后台前端的路由 name;按钮 uri 等于接口路径(或路由指定的 permission_uri)。
+ * 每个写接口都有按钮行:没有按钮行的接口不做角色校验。
+ */
+return new class extends Migration {
+    private const PARENT = 'SimulationManagement';
+
+    /** 菜单 uri => [标题, [按钮 uri => 标题]] */
+    private const MENUS = [
+        'SimulationOverview' => ['模拟概览', ['admin/simulation/overview' => '查看']],
+        'SimulationMembers' => ['模拟会员', ['admin/simulation/members' => '查看', 'admin/simulation/members/create' => '创建会员',
+            'admin/simulation/members/banned' => '封禁/解封', 'admin/simulation/members/login' => '模拟登录', 'admin/simulation/members/logout' => '模拟退出']],
+        'SimulationFunds' => ['模拟资金', ['admin/simulation/funds' => '查看', 'admin/simulation/funds/recharge' => '模拟充值',
+            'admin/simulation/funds/withdraw' => '模拟提现', 'admin/simulation/funds/withdraw/audit' => '提现审核', 'admin/simulation/funds/adjust' => '人工调账',
+            'admin/simulation/funds/freeze' => '冻结', 'admin/simulation/funds/unfreeze' => '解冻']],
+        'SimulationBets' => ['模拟注单', ['admin/simulation/bets' => '查看', 'admin/simulation/bets/lottery' => '彩票下注', 'admin/simulation/bets/sport' => '体育下注',
+            'admin/simulation/bets/sport/settle' => '体育结算', 'admin/simulation/bets/sport/cancel' => '体育撤单']],
+        'SimulationTasks' => ['自动任务', ['admin/simulation/tasks' => '查看', 'admin/simulation/tasks/templates' => '保存模板',
+            'admin/simulation/tasks/templates/delete' => '删除模板', 'admin/simulation/tasks/runs' => '启动任务', 'admin/simulation/tasks/runs/status' => '暂停/继续/停止']],
+        'SimulationReports' => ['模拟报表', ['admin/simulation/reports' => '查看', 'admin/simulation/reports/refresh' => '立即刷新']],
+        'SimulationLogs' => ['操作日志', ['admin/simulation/logs' => '查看']],
+        'SimulationSettings' => ['模拟设置', ['admin/simulation/settings' => '查看', 'admin/simulation/settings/save' => '保存设置', 'admin/simulation/reset' => '重置模拟数据']],
+    ];
+
+    public function up(): void
+    {
+        if (!Schema::hasTable('menus')) return;
+        $parentId = $this->upsert(self::PARENT, ['parent_id' => 0, 'title' => '模拟管理', 'type' => 1, 'sort' => 99, 'permission_name' => 'simulation']);
+        $sort = 0;
+        foreach (self::MENUS as $uri => [$title, $buttons]) {
+            $menuId = $this->upsert($uri, ['parent_id' => $parentId, 'title' => $title, 'type' => 1, 'sort' => $sort++, 'permission_name' => 'simulation.' . $uri]);
+            $order = 0;
+            foreach ($buttons as $buttonUri => $buttonTitle) {
+                $this->upsert($buttonUri, ['parent_id' => $menuId, 'title' => $title . '-' . $buttonTitle, 'type' => 2, 'sort' => $order++,
+                    'permission_name' => str_replace('/', '.', $buttonUri)]);
+            }
+        }
+    }
+
+    private function upsert(string $uri, array $values): int
+    {
+        $now = date('Y-m-d H:i:s');
+        $existing = DB::table('menus')->where('uri', $uri)->first(['id']);
+        if ($existing) {
+            DB::table('menus')->where('id', $existing->id)->update($values + ['status' => 1, 'updated_at' => $now]);
+            return (int) $existing->id;
+        }
+        return (int) DB::table('menus')->insertGetId($values + ['uri' => $uri, 'icon' => null, 'status' => 1, 'created_at' => $now, 'updated_at' => $now]);
+    }
+
+    public function down(): void
+    {
+        if (!Schema::hasTable('menus')) return;
+        $uris = [self::PARENT];
+        foreach (self::MENUS as $uri => [, $buttons]) $uris = array_merge($uris, [$uri], array_keys($buttons));
+        $ids = DB::table('menus')->whereIn('uri', $uris)->pluck('id');
+        if (Schema::hasTable('role_menu')) DB::table('role_menu')->whereIn('menu_id', $ids)->delete();
+        DB::table('menus')->whereIn('id', $ids)->delete();
+    }
+};

+ 51 - 0
routes/admin.php

@@ -571,6 +571,57 @@ Route::middleware(['admin.jwt'])->group(function () {
         });
         });
 
 
         
         
+        // 模拟管理:只有这里能看到后台模拟会员(users.user_type = 2)的数据。查看类接口按所在菜单的“查看”权限校验,写接口各有按钮权限。
+        Route::prefix('/simulation')->group(function () {
+            Route::get('/overview', [\App\Http\Controllers\admin\Simulation::class, 'overview'])->defaults('permission_uri', 'admin/simulation/overview');
+            Route::get('/members', [\App\Http\Controllers\admin\Simulation::class, 'members'])->defaults('permission_uri', 'admin/simulation/members');
+            Route::post('/members', [\App\Http\Controllers\admin\Simulation::class, 'createMembers'])->defaults('permission_uri', 'admin/simulation/members/create');
+            Route::get('/members/detail', [\App\Http\Controllers\admin\Simulation::class, 'memberDetail'])->defaults('permission_uri', 'admin/simulation/members');
+            Route::post('/members/banned', [\App\Http\Controllers\admin\Simulation::class, 'memberBanned']);
+            Route::post('/members/login', [\App\Http\Controllers\admin\Simulation::class, 'memberLogin']);
+            Route::post('/members/logout', [\App\Http\Controllers\admin\Simulation::class, 'memberLogout']);
+            Route::get('/members/login-logs', [\App\Http\Controllers\admin\Simulation::class, 'loginLogs'])->defaults('permission_uri', 'admin/simulation/members');
+            Route::get('/funds/recharges', [\App\Http\Controllers\admin\Simulation::class, 'recharges'])->defaults('permission_uri', 'admin/simulation/funds');
+            Route::post('/funds/recharge', [\App\Http\Controllers\admin\Simulation::class, 'recharge']);
+            Route::get('/funds/withdrawals', [\App\Http\Controllers\admin\Simulation::class, 'withdrawals'])->defaults('permission_uri', 'admin/simulation/funds');
+            Route::post('/funds/withdraw', [\App\Http\Controllers\admin\Simulation::class, 'withdraw']);
+            Route::post('/funds/withdraw/audit', [\App\Http\Controllers\admin\Simulation::class, 'withdrawAudit']);
+            Route::post('/funds/adjust', [\App\Http\Controllers\admin\Simulation::class, 'adjust']);
+            Route::get('/funds/ledger', [\App\Http\Controllers\admin\Simulation::class, 'ledger'])->defaults('permission_uri', 'admin/simulation/funds');
+            Route::get('/funds/freezes', [\App\Http\Controllers\admin\Simulation::class, 'freezes'])->defaults('permission_uri', 'admin/simulation/funds');
+            Route::post('/funds/freeze', [\App\Http\Controllers\admin\Simulation::class, 'freeze']);
+            Route::post('/funds/unfreeze', [\App\Http\Controllers\admin\Simulation::class, 'unfreeze']);
+            Route::get('/bets/options', [\App\Http\Controllers\admin\Simulation::class, 'betOptions'])->defaults('permission_uri', 'admin/simulation/bets');
+            Route::get('/bets/lottery', [\App\Http\Controllers\admin\Simulation::class, 'lotteryBets'])->defaults('permission_uri', 'admin/simulation/bets');
+            Route::post('/bets/lottery', [\App\Http\Controllers\admin\Simulation::class, 'lotteryBet']);
+            Route::get('/bets/sport', [\App\Http\Controllers\admin\Simulation::class, 'sportBets'])->defaults('permission_uri', 'admin/simulation/bets');
+            Route::post('/bets/sport', [\App\Http\Controllers\admin\Simulation::class, 'sportBet']);
+            Route::post('/bets/sport/settle', [\App\Http\Controllers\admin\Simulation::class, 'sportSettle']);
+            Route::post('/bets/sport/cancel', [\App\Http\Controllers\admin\Simulation::class, 'sportCancel']);
+            Route::get('/bets/third', [\App\Http\Controllers\admin\Simulation::class, 'thirdBets'])->defaults('permission_uri', 'admin/simulation/bets');
+            Route::get('/tasks/templates', [\App\Http\Controllers\admin\Simulation::class, 'templates'])->defaults('permission_uri', 'admin/simulation/tasks');
+            Route::post('/tasks/templates', [\App\Http\Controllers\admin\Simulation::class, 'saveTemplate']);
+            Route::post('/tasks/templates/delete', [\App\Http\Controllers\admin\Simulation::class, 'deleteTemplate']);
+            Route::get('/tasks/runs', [\App\Http\Controllers\admin\Simulation::class, 'runs'])->defaults('permission_uri', 'admin/simulation/tasks');
+            Route::post('/tasks/runs', [\App\Http\Controllers\admin\Simulation::class, 'startRun']);
+            Route::post('/tasks/runs/status', [\App\Http\Controllers\admin\Simulation::class, 'runStatus']);
+            Route::get('/tasks/events', [\App\Http\Controllers\admin\Simulation::class, 'events'])->defaults('permission_uri', 'admin/simulation/tasks');
+            Route::get('/reports/users', [\App\Http\Controllers\admin\Simulation::class, 'reportUsers'])->defaults('permission_uri', 'admin/simulation/reports');
+            Route::get('/reports/funds', [\App\Http\Controllers\admin\Simulation::class, 'reportFunds'])->defaults('permission_uri', 'admin/simulation/reports');
+            Route::get('/reports/home', [\App\Http\Controllers\admin\Simulation::class, 'reportHome'])->defaults('permission_uri', 'admin/simulation/reports');
+            Route::get('/reports/games', [\App\Http\Controllers\admin\Simulation::class, 'reportGames'])->defaults('permission_uri', 'admin/simulation/reports');
+            Route::get('/reports/fund-flow', [\App\Http\Controllers\admin\Simulation::class, 'reportFundFlow'])->defaults('permission_uri', 'admin/simulation/reports');
+            Route::get('/reports/registrations', [\App\Http\Controllers\admin\Simulation::class, 'reportRegistrations'])->defaults('permission_uri', 'admin/simulation/reports');
+            Route::get('/reports/domains', [\App\Http\Controllers\admin\Simulation::class, 'reportDomains'])->defaults('permission_uri', 'admin/simulation/reports');
+            Route::post('/reports/refresh', [\App\Http\Controllers\admin\Simulation::class, 'reportRefresh']);
+            Route::get('/logs/audits', [\App\Http\Controllers\admin\Simulation::class, 'audits'])->defaults('permission_uri', 'admin/simulation/logs');
+            Route::get('/logs/events', [\App\Http\Controllers\admin\Simulation::class, 'events'])->defaults('permission_uri', 'admin/simulation/logs');
+            Route::get('/settings', [\App\Http\Controllers\admin\Simulation::class, 'settings'])->defaults('permission_uri', 'admin/simulation/settings');
+            Route::post('/settings', [\App\Http\Controllers\admin\Simulation::class, 'saveSettings'])->middleware('operations.admin')->defaults('permission_uri', 'admin/simulation/settings/save');
+            Route::get('/reset/preview', [\App\Http\Controllers\admin\Simulation::class, 'resetPreview'])->middleware('operations.admin')->defaults('permission_uri', 'admin/simulation/reset');
+            Route::post('/reset', [\App\Http\Controllers\admin\Simulation::class, 'reset'])->middleware('operations.admin');
+        });
+
         Route::prefix('/sportGame')->group(function () {
         Route::prefix('/sportGame')->group(function () {
             Route::get('/list', [SportGame::class, 'list']);
             Route::get('/list', [SportGame::class, 'list']);
             Route::post('/setOdds', [SportGame::class, 'setOdds']);
             Route::post('/setOdds', [SportGame::class, 'setOdds']);

+ 324 - 0
tests/Integration/SimulationTest.php

@@ -0,0 +1,324 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Simulation as SimulationController;
+use App\Models\User;
+use App\Services\Simulation\SimulationBetService;
+use App\Services\Simulation\SimulationFundService;
+use App\Services\Simulation\SimulationMemberService;
+use App\Services\Simulation\SimulationResetService;
+use App\Services\Simulation\SimulationRunner;
+use App\Services\Simulation\SimulationSettings;
+use App\Services\Simulation\SimulationTaskService;
+use App\Services\UserService;
+use App\Support\MemberAudience;
+use App\Support\StatsScope;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Schema;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * 模拟管理:模拟会员的充值、下注、结算、提现写真实业务表,但正式口径的统计和列表完全看不到;
+ * 模拟口径只看到模拟会员;自动任务能跑完并可重复调度;重置只删模拟数据。
+ */
+class SimulationTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        Facade::clearResolvedInstances();
+        $this->app = $app = new Application(dirname(__DIR__, 2));
+        $app->instance('config', new Repository(['app' => ['timezone' => 'Asia/Shanghai', 'locale' => 'zh'], 'admin_realtime' => ['enabled' => false],
+            'cache' => ['default' => 'array', 'stores' => ['array' => ['driver' => 'array']], 'prefix' => '']]));
+        $app->instance('files', new \Illuminate\Filesystem\Filesystem());
+        $app->instance('path.lang', dirname(__DIR__, 2) . '/lang');
+        $app->register(\Illuminate\Translation\TranslationServiceProvider::class);
+        $app->register(\Illuminate\Validation\ValidationServiceProvider::class);
+        $app->register(\Illuminate\Cache\CacheServiceProvider::class);
+        $app->instance('log', new \Psr\Log\NullLogger());
+        $app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, (new \ReflectionClass(\Illuminate\Routing\ResponseFactory::class))->newInstanceWithoutConstructor());
+        Request::macro('validate', function (array $rules) { return validator()->validate($this->all(), $rules); });
+        Facade::setFacadeApplication($app);
+        $db = new Manager($app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setEventDispatcher(new Dispatcher($app));
+        $db->setAsGlobal();
+        $db->bootEloquent();
+        $app->instance('db.transactions', new \Illuminate\Database\DatabaseTransactionsManager());
+        $app->instance('db', $db->getDatabaseManager());
+        $app->bind('db.schema', fn () => $db->schema());
+        $this->request();
+        $this->schema();
+        $this->seedRealMember();
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    private function request(array $input = [], string $method = 'GET'): void
+    {
+        $request = Request::create('/admin/simulation', $method, $input);
+        $request->user = (object) ['id' => 1, 'username' => 'root'];
+        $this->app->instance('request', $request);
+    }
+
+    private function schema(): void
+    {
+        Schema::create('users', function (Blueprint $t) {
+            $t->id(); $t->string('member_id')->nullable(); $t->string('user_id')->nullable(); $t->string('account')->nullable();
+            $t->string('first_name')->nullable(); $t->string('username')->default(''); $t->string('from', 20)->nullable()->default('-1');
+            $t->unsignedBigInteger('agent_id')->nullable(); $t->string('agent_user_code')->nullable(); $t->string('user_code')->nullable();
+            $t->integer('is_banned')->default(0); $t->integer('status')->default(0); $t->integer('level')->default(0);
+            $t->string('register_ip')->default(''); $t->string('register_domain')->default(''); $t->integer('last_active_time')->default(0);
+            $t->string('language')->default('zh'); $t->integer('recharge_channel_group_id')->default(1); $t->string('admin_note')->default('');
+            $t->timestamps();
+        });
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_120000_add_user_type_to_users.php')->up();
+        Schema::create('wallets', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('user_id')->default(0); $t->string('member_id'); $t->string('coin')->nullable(); $t->string('net')->nullable();
+            $t->decimal('available_balance', 30, 10)->default(0); $t->decimal('frozen_balance', 30, 10)->default(0); $t->timestamps();
+        });
+        Schema::create('balance_logs', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->decimal('amount', 30, 10); $t->decimal('before_balance', 30, 10)->default(0);
+            $t->decimal('after_balance', 30, 10)->default(0); $t->string('change_type'); $t->string('related_id')->nullable(); $t->string('remark')->nullable();
+            $t->integer('type')->default(1); $t->string('room_id')->nullable(); $t->timestamps();
+        });
+        Schema::create('payment_orders', function (Blueprint $t) {
+            $t->id(); $t->integer('type'); $t->string('order_no')->nullable(); $t->string('member_id'); $t->decimal('amount', 24, 10);
+            $t->string('channel')->nullable(); $t->string('bank_name')->nullable(); $t->string('account')->nullable(); $t->string('card_no')->nullable();
+            $t->integer('status'); $t->string('state')->nullable(); $t->text('callback_data')->nullable(); $t->string('remark')->nullable();
+            $t->decimal('fee', 24, 8)->default(0); $t->decimal('fee_rate', 12, 6)->default(0); $t->decimal('fee_fixed', 12, 4)->default(0);
+            $t->decimal('frozen_amount', 24, 10)->default(0); $t->text('recharge_bonus_snapshot')->nullable(); $t->timestamps();
+        });
+        Schema::create('level', function (Blueprint $t) { $t->id(); $t->integer('level'); $t->decimal('recharge', 20, 2); });
+        Schema::create('config', function (Blueprint $t) { $t->id(); $t->string('field'); $t->string('val')->nullable(); });
+        Schema::create('issues', function (Blueprint $t) {
+            $t->id(); $t->string('issue_no'); $t->dateTime('start_time'); $t->dateTime('end_time'); $t->integer('status'); $t->timestamps();
+        });
+        Schema::create('gameplay_rules', function (Blueprint $t) {
+            $t->id(); $t->string('keywords'); $t->decimal('odds', 10, 2); $t->decimal('mininum', 12, 2); $t->decimal('maxinum', 12, 2); $t->timestamps();
+        });
+        Schema::create('lhc_order', function (Blueprint $t) {
+            $t->id(); $t->string('issue'); $t->string('ordernum'); $t->string('member_id'); $t->decimal('odds', 10, 2)->default(0);
+            $t->decimal('amount', 22, 2); $t->decimal('total_amount', 22, 2)->default(0); $t->integer('lottery_status')->default(0);
+            $t->decimal('win_amount', 22, 2)->default(0); $t->decimal('profit_and_loss', 22, 2)->nullable(); $t->integer('type')->default(5);
+            $t->integer('is_faker')->default(0); $t->string('bet_source', 16)->nullable(); $t->integer('created_at')->nullable(); $t->integer('updated_at')->nullable();
+        });
+        Schema::create('lhc_order_item', function (Blueprint $t) {
+            $t->id(); $t->string('ordernum'); $t->string('member_id'); $t->string('number'); $t->integer('lottery_status')->default(0);
+            $t->timestamps();
+        });
+        Schema::create('sport_order', function (Blueprint $t) {
+            $t->id(); $t->string('user_id'); $t->string('order_id'); $t->string('issue'); $t->text('detail')->nullable(); $t->integer('is_roll')->default(0);
+            $t->decimal('amount', 22, 2); $t->decimal('cny_rate', 22, 10)->nullable(); $t->decimal('win_amount', 22, 2)->default(0);
+            $t->decimal('return_amount', 22, 2)->default(0); $t->decimal('profit_and_loss', 22, 2)->default(0); $t->string('game_result')->nullable();
+            $t->integer('is_faker')->default(0); $t->integer('is_win')->default(0); $t->integer('status')->default(0); $t->integer('settlement_status')->default(0);
+            $t->integer('pay_status')->default(0); $t->integer('pay_time')->nullable(); $t->string('odd_name')->nullable(); $t->decimal('odd', 12, 6)->nullable();
+            $t->string('odd_value')->nullable(); $t->string('remark')->nullable(); $t->integer('create_time')->default(0); $t->integer('update_time')->default(0);
+            $t->integer('settlement_time')->nullable();
+        });
+        Schema::create('user_login', function (Blueprint $t) {
+            $t->id(); $t->string('user_id')->nullable(); $t->string('login_account')->default(''); $t->string('login_ip')->default('');
+            $t->string('login_domain')->default(''); $t->string('platform')->default(''); $t->integer('status')->default(1);
+            $t->dateTime('logout_time')->nullable(); $t->integer('online_duration')->default(0); $t->timestamps();
+        });
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_130000_create_simulation_tables.php')->up();
+        DB::table('config')->insert([['field' => 'pc28_switch', 'val' => '0'], ['field' => 'recharge_rate', 'val' => '0']]);
+        DB::table('config')->where('field', 'simulation_enabled')->update(['val' => '1']);
+        DB::table('issues')->insert(['issue_no' => '3300001', 'start_time' => now(), 'end_time' => now()->addMinutes(5), 'status' => 1]);
+        DB::table('gameplay_rules')->insert(['keywords' => '大', 'odds' => 2, 'mininum' => 1, 'maxinum' => 100000]);
+    }
+
+    /** 一个真实会员和一个前台游客,各有充值、投注、订单。 */
+    private function seedRealMember(): void
+    {
+        foreach ([['r1', '1', 0], ['g1', '2', 1]] as $i => [$member, $from, $type]) {
+            DB::table('users')->insert(['member_id' => $member, 'user_id' => $member, 'account' => $member, 'first_name' => $member, 'from' => $from,
+                'user_type' => $type, 'created_at' => now(), 'updated_at' => now()]);
+            DB::table('wallets')->insert(['user_id' => $i + 1, 'member_id' => $member, 'available_balance' => 90]);
+            DB::table('balance_logs')->insert([
+                ['member_id' => $member, 'amount' => 100, 'change_type' => '三方充值', 'created_at' => now(), 'updated_at' => now()],
+                ['member_id' => $member, 'amount' => -10, 'change_type' => '投注', 'created_at' => now(), 'updated_at' => now()]]);
+            DB::table('payment_orders')->insert(['type' => 1, 'member_id' => $member, 'amount' => 100, 'status' => 2, 'created_at' => now(), 'updated_at' => now()]);
+            DB::table('lhc_order')->insert(['issue' => '3300001', 'ordernum' => 'o' . $member, 'member_id' => $member, 'amount' => 10, 'total_amount' => 10]);
+        }
+    }
+
+    /** 正式口径下各处看到的数字。模拟数据进来前后必须完全一样。 */
+    private function realView(): array
+    {
+        return [
+            'stats_deposit' => (float) StatsScope::excludeTestMembers(DB::table('balance_logs'))->where('change_type', '三方充值')->sum('amount'),
+            'stats_bet' => (float) StatsScope::excludeTestMembers(DB::table('balance_logs'))->where('change_type', 'like', '%投注')->sum('amount'),
+            'stats_wallet' => (float) StatsScope::excludeTestMembers(DB::table('wallets'))->sum('available_balance'),
+            'stats_orders' => StatsScope::excludeTestMembers(DB::table('payment_orders'))->count(),
+            'real_users' => StatsScope::realUsers(DB::table('users'))->pluck('member_id')->all(),
+            'guest_users' => StatsScope::guestUsers(DB::table('users'))->pluck('member_id')->all(),
+            'raw_users' => array_column(DB::select('SELECT u.member_id FROM bot_users u WHERE 1 = 1' . StatsScope::rawExcludeTestUsers('u')), 'member_id'),
+            'list_users' => MemberAudience::visibleUsers(DB::table('users'))->orderBy('id')->pluck('member_id')->all(),
+            'list_ledger' => MemberAudience::visibleUsers(DB::table('balance_logs')->join('users', 'users.member_id', '=', 'balance_logs.member_id'))->count(),
+            'list_orders' => MemberAudience::visibleMembers(DB::table('payment_orders'))->count(),
+            'list_lottery' => MemberAudience::visibleMembers(DB::table('lhc_order'), 'lhc_order.member_id')->count(),
+            'list_sport' => MemberAudience::visibleMembers(DB::table('sport_order'), 'user_id', 'user_id')->count(),
+            'list_logins' => MemberAudience::visibleMembers(DB::table('user_login'), 'user_id', 'user_id')->count(),
+        ];
+    }
+
+    private function wallet(string $memberId): array
+    {
+        $w = DB::table('wallets')->where('member_id', $memberId)->first();
+        return [(float) $w->available_balance, (float) $w->frozen_balance];
+    }
+
+    public function test_simulated_money_and_bets_use_real_tables_but_stay_out_of_the_real_view(): void
+    {
+        $before = $this->realView();
+        $this->assertSame(100.0, $before['stats_deposit'], 'guest deposits are not counted either');
+        $this->assertSame(['r1'], $before['real_users']);
+        $this->assertSame(['g1'], $before['guest_users']);
+        $this->assertSame(['r1', 'g1'], $before['list_users'], 'lists still show guests');
+
+        [$a, $b] = array_column(SimulationMemberService::create(2, ['initial_balance' => 1000]), 'member_id');
+        $user = DB::table('users')->where('member_id', $a)->first();
+        $this->assertSame(['2', 2, null], [(string) $user->from, (int) $user->user_type, $user->agent_id]);
+        $this->assertSame([1000.0, 0.0], $this->wallet($a));
+
+        // 充值:三方成功、公司入款成功、失败的不入账
+        $this->assertSame(2, (int) SimulationFundService::recharge($a, 500, 'third')->status);
+        $this->assertSame(2, (int) SimulationFundService::recharge($a, 200, 'company')->status);
+        $this->assertSame(3, (int) SimulationFundService::recharge($a, 999, 'third', 'fail')->status);
+        $this->assertSame(1, (int) SimulationFundService::recharge($a, 50, 'third', 'pending')->status);
+        $this->assertSame(1700.0, $this->wallet($a)[0]);
+
+        // 彩票下在真实期号上;体育按给定结果结算
+        $lottery = SimulationBetService::lottery($a, '大', 100);
+        $this->assertSame(['3300001', 'simulation', 0], [$lottery->issue, $lottery->bet_source, (int) $lottery->fresh()->lottery_status]);
+        $win = SimulationBetService::sport($a, 50, 2);
+        $this->assertSame('100.00', SimulationBetService::settleSport($win->id, 'win')->win_amount);
+        $half = SimulationBetService::sport($a, 40, 1.9);
+        $settled = SimulationBetService::settleSport($half->id, 'half');
+        $this->assertSame(['20.00', '-20.00'], [$settled->return_amount, $settled->profit_and_loss]);
+        try { SimulationBetService::settleSport($half->id, 'win'); $this->fail('an order settles once'); }
+        catch (\Exception $e) { $this->assertStringContainsString('已结算', $e->getMessage()); }
+        $this->assertSame(1630.0, $this->wallet($a)[0], '1700 - 100 - 50 + 100 - 40 + 20');
+        $this->assertSame($before, $this->realView());
+    }
+
+    public function test_full_flow_isolation_runner_and_reset(): void
+    {
+        $before = $this->realView();
+        [$a] = array_column(SimulationMemberService::create(1, ['initial_balance' => 1000]), 'member_id');
+        SimulationFundService::recharge($a, 500, 'third');
+        SimulationFundService::recharge($a, 200, 'company');
+        SimulationBetService::lottery($a, '大', 100);
+        $win = SimulationBetService::sport($a, 50, 2);
+        SimulationBetService::settleSport($win->id, 'win');
+        $draw = SimulationBetService::sport($a, 30, 2);
+        SimulationBetService::settleSport($draw->id, 'draw');
+        $lose = SimulationBetService::sport($a, 20, 2);
+        SimulationBetService::settleSport($lose->id, 'lose');
+        $cancelled = SimulationBetService::sport($a, 10, 2);
+        SimulationBetService::cancelSport($cancelled->id);
+        $approved = SimulationFundService::withdrawApply($a, 300);
+        SimulationFundService::withdrawAudit($approved->id, true);
+        $rejected = SimulationFundService::withdrawApply($a, 70);
+        SimulationFundService::withdrawAudit($rejected->id, false, '测试拒绝');
+        SimulationFundService::adjust($a, 5, 'debit', '扣款');
+        SimulationMemberService::login($a);
+
+        // 1000 + 500 + 200 - 100 - 50 + 100 - 30 + 30 - 20 - 10 + 10 - 300 - 70 + 70 - 5
+        $this->assertSame([1325.0, 0.0], $this->wallet($a));
+        $this->assertSame([2, 3], [(int) $approved->fresh()->status, (int) $rejected->fresh()->status]);
+
+        $this->assertSame($before, $this->realView(), 'nothing the real back office reads has changed');
+
+        $sim = MemberAudience::simulated(fn () => [
+            'deposit' => (float) StatsScope::excludeTestMembers(DB::table('balance_logs'))->whereIn('change_type', ['三方充值', '充值'])->sum('amount'),
+            'bet' => (float) StatsScope::excludeTestMembers(DB::table('balance_logs'))->where('change_type', 'like', '%投注')->sum('amount'),
+            'users' => StatsScope::realUsers(DB::table('users'))->pluck('member_id')->all(),
+            'raw' => array_column(DB::select('SELECT u.member_id FROM bot_users u WHERE 1 = 1' . StatsScope::rawExcludeTestUsers('u')), 'member_id'),
+            'guests' => StatsScope::guestUsers(DB::table('users'))->count(),
+            'list' => MemberAudience::visibleUsers(DB::table('users'))->pluck('member_id')->all(),
+            'sport' => MemberAudience::visibleMembers(DB::table('sport_order'), 'user_id', 'user_id')->count(),
+        ]);
+        $this->assertSame(['deposit' => 700.0, 'bet' => -210.0, 'users' => [$a], 'raw' => [$a], 'guests' => 0, 'list' => [$a], 'sport' => 4], $sim);
+        $this->assertFalse(MemberAudience::isSimulated(), 'audience is restored');
+
+        // 概览接口与手算一致
+        $this->request();
+        $overview = (new SimulationController())->overview()->getData(true);
+        $this->assertSame(0, $overview['code'], $overview['msg']);
+        $this->assertSame(['1', '1'], [(string) $overview['data']['members']['total'], (string) $overview['data']['members']['online']]);
+        $this->assertSame(['700.00', '1000.00', '5.00', '300.00', '1325.00'], [$overview['data']['funds']['recharge'], $overview['data']['funds']['manual_credit'],
+            $overview['data']['funds']['manual_debit'], $overview['data']['funds']['withdraw'], $overview['data']['funds']['wallet_available']]);
+        $this->assertSame(['210.00', '140.00', 1], [$overview['data']['bets']['bet_amount'], $overview['data']['bets']['payout'], $overview['data']['bets']['unsettled']]);
+
+        // 自动任务:注册 2 人 → 登录 → 充值 → 体育下注 2 次 → 结算 → 提现 → 退出
+        $steps = [['type' => 'register', 'interval' => 0], ['type' => 'login', 'interval' => 0], ['type' => 'recharge', 'amount' => 100, 'interval' => 0],
+            ['type' => 'bet', 'category' => 'sport', 'amount' => 10, 'count' => 2, 'interval' => 0], ['type' => 'settle', 'settle_mode' => 'lose', 'interval' => 0],
+            ['type' => 'withdraw', 'amount' => 30, 'interval' => 0], ['type' => 'logout', 'interval' => 0]];
+        $runId = SimulationTaskService::start(['name' => '完整流程', 'steps' => $steps, 'member_count' => 2], 1);
+        $paused = SimulationTaskService::setStatus($runId, 'pause');
+        $this->assertSame('paused', $paused->status);
+        $this->assertSame(0, SimulationRunner::tick(), 'a paused run does nothing');
+        SimulationTaskService::setStatus($runId, 'resume');
+        for ($i = 0; $i < 60 && DB::table('simulation_task_runs')->where('id', $runId)->value('status') === 'running'; $i++) SimulationRunner::tick();
+        $run = DB::table('simulation_task_runs')->where('id', $runId)->first();
+        $this->assertSame(['completed', 16, 16, 0], [$run->status, (int) $run->total, (int) $run->completed, (int) $run->failed], (string) $run->last_error);
+        $this->assertSame(16, DB::table('simulation_task_events')->where('run_id', $runId)->where('status', 'ok')->count());
+        $this->assertSame(0, SimulationRunner::tick(), 'a finished run is not executed again');
+        foreach (json_decode($run->member_ids, true) as $memberId) $this->assertSame([50.0, 0.0], $this->wallet($memberId), '100 - 10 - 10 - 30');
+        $this->assertSame(3, DB::table('users')->where('user_type', 2)->count());
+
+        $this->assertSame($before, $this->realView(), 'the task run did not leak either');
+
+        // 重置:只删模拟数据
+        $preview = SimulationResetService::preview();
+        $this->assertSame(3, $preview['counts']['users']);
+        try { SimulationResetService::reset('wrong', SimulationResetService::CONFIRM_TEXT); $this->fail('wrong token must be refused'); }
+        catch (\Exception $e) { $this->assertStringContainsString('失效', $e->getMessage()); }
+        $preview = SimulationResetService::preview();
+        $deleted = SimulationResetService::reset($preview['confirm_token'], $preview['confirm_text']);
+        $this->assertSame(3, $deleted['users']);
+        $this->assertSame(0, DB::table('users')->where('user_type', 2)->count());
+        $this->assertSame([2, 2, 4, 2, 2], [DB::table('users')->count(), DB::table('wallets')->count(), DB::table('balance_logs')->count(),
+            DB::table('payment_orders')->count(), DB::table('lhc_order')->count()], 'real and guest rows are untouched');
+        $this->assertSame([0, 0], [DB::table('sport_order')->count(), DB::table('user_login')->count()]);
+        $this->assertSame($before, $this->realView());
+    }
+
+    public function test_guards_refuse_real_members_and_a_closed_switch(): void
+    {
+        foreach ([fn () => SimulationFundService::recharge('r1', 10), fn () => SimulationFundService::adjust('g1', 10, 'credit', 'x'),
+            fn () => SimulationBetService::sport('r1', 10, 2), fn () => SimulationFundService::withdrawApply('r1', 10)] as $call) {
+            try { $call(); $this->fail('real members and guests must be refused'); }
+            catch (\Exception $e) { $this->assertSame('只能操作模拟会员', $e->getMessage()); }
+        }
+        $this->assertSame(90.0, $this->wallet('r1')[0]);
+
+        $order = \App\Models\PaymentOrder::create(['type' => 1, 'member_id' => 'r1', 'amount' => 10, 'status' => 1]);
+        try { \App\Services\PaymentOrderService::simulatePaySuccess($order); $this->fail('a real order must not be credited'); }
+        catch (\Exception $e) { $this->assertStringContainsString('模拟会员', $e->getMessage()); }
+
+        SimulationSettings::save(['simulation_enabled' => 0]);
+        try { SimulationMemberService::create(1); $this->fail('closed switch'); }
+        catch (\Exception $e) { $this->assertStringContainsString('未开启', $e->getMessage()); }
+        $this->assertSame(0, SimulationRunner::tick());
+        $this->assertSame(0, User::where('user_type', 2)->count());
+        $this->assertNotNull(UserService::class);
+    }
+}