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); } }