app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository(['app' => ['locale' => 'zh'], 'agent' => []])); Facade::setFacadeApplication($this->app); $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']); $db->setEventDispatcher(new Dispatcher($this->app)); $db->setAsGlobal(); $db->bootEloquent(); // 生产环境由 DatabaseServiceProvider 绑定;DB::afterCommit 依赖它。 $this->app->instance('db.transactions', new \Illuminate\Database\DatabaseTransactionsManager()); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema()); $this->app->instance('hash', new BcryptHasher(['rounds' => 4])); $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $validator = new Factory($translator, $this->app); $validator->setPresenceVerifier(new \Illuminate\Validation\DatabasePresenceVerifier($db->getDatabaseManager())); $this->app->instance('validator', $validator); $this->app->instance('request', Request::create('/')); Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); }); $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new JsonResponseFactory()); Schema::create('users', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->string('username'); $t->string('first_name')->default(''); $t->integer('status')->default(1); $t->string('register_ip')->default(''); $t->string('register_domain')->default(''); $t->timestamps(); }); // The profit migration inspects MySQL index metadata before creating indexes. DB::connection()->getPdo()->sqliteCreateFunction('DATABASE', static fn () => 'isolated'); DB::statement("ATTACH DATABASE ':memory:' AS information_schema"); DB::statement('CREATE TABLE information_schema.statistics (table_schema TEXT,table_name TEXT,index_name TEXT,column_name TEXT,non_unique INTEGER,seq_in_index INTEGER)'); foreach (['2026_08_24_140000_create_agent_system_tables.php', '2026_08_28_120000_create_agent_profit_profiles_and_rebate_records.php'] as $file) { (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up(); } // Pre-upgrade flow commission and owner columns (their MySQL-only migrations use information_schema). Schema::create('agent_flow_commission_profiles', function (Blueprint $t) { $t->id(); $t->string('name'); $t->integer('is_default')->default(0); $t->timestamps(); }); Schema::create('agent_flow_commission_assignments', function (Blueprint $t) { $t->id(); $t->unsignedBigInteger('agent_id'); $t->date('effective_from'); $t->timestamps(); }); Schema::table('agents', fn (Blueprint $t) => $t->unsignedBigInteger('flow_commission_profile_id')->nullable()->index()); Schema::table('agents', fn (Blueprint $t) => $t->string('language', 8)->default('zh')); Schema::table('agents', fn (Blueprint $t) => $t->unsignedBigInteger('owner_user_id')->nullable()); Schema::table('agent_profit_commission_profiles', fn (Blueprint $t) => $t->unsignedBigInteger('owner_agent_id')->nullable()); Schema::create('share_system_state', function (Blueprint $t) { $t->string('key')->primary(); $t->text('value')->nullable(); $t->timestamps(); }); Schema::create('share_policies', function (Blueprint $t) { $t->id(); $t->date('effective_date'); $t->json('rules'); $t->timestamps(); }); DB::table('share_policies')->insert(['effective_date' => '1970-01-01', 'rules' => json_encode(['third:1' => [], 'third:3' => [], 'self:lottery' => []])]); (require dirname(__DIR__, 2) . '/database/migrations/2026_11_07_120000_agent_joint_venture_and_equity.php')->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_180000_add_agent_equity_order_admin.php')->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_190000_add_agent_recharge_locks.php')->up(); foreach (['recharges', 'withdraws', 'payment_orders'] as $table) { Schema::create($table, function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->integer('status'); $t->string('type')->nullable(); $t->decimal('amount', 18, 4); $t->timestamps(); }); } Schema::create('wallets', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->decimal('available_balance', 18, 4)->default(0); $t->decimal('frozen_balance', 18, 4)->default(0); }); Schema::create('balance_logs', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->string('change_type'); $t->integer('related_id')->nullable(); $t->decimal('amount', 18, 4); $t->timestamps(); }); Schema::create('config', function (Blueprint $t) { $t->id(); $t->string('field'); $t->string('val')->nullable(); }); Schema::create('share_bet_facts', function (Blueprint $t) { $t->id(); $t->string('source', 32); $t->string('source_id', 64); $t->unsignedBigInteger('user_id'); $t->string('member_id', 64)->default(''); $t->string('category', 32); $t->dateTime('bet_at'); $t->date('bet_date'); $t->dateTime('settled_at')->nullable(); foreach (['bet_amount', 'net_profit', 'effective_amount'] as $c) $t->decimal($c, 24, 10)->default(0); $t->boolean('settled')->default(false); $t->boolean('voided')->default(false); $t->timestamps(); }); $this->agents = new AgentService(); } protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); Carbon::setTestNow(); parent::tearDown(); } /** 充值/提现通道(tests/fixtures 的线上快照)、支付公司开关,以及代理人民币充值、提现通道迁移。可重复调用。 */ private function paymentChannels(): void { if (Schema::hasTable('recharge_channel')) return; Schema::create('recharge_channel', function (Blueprint $t) { $t->id(); $t->integer('from'); $t->integer('data_type'); $t->string('key'); $t->string('name'); $t->string('type'); $t->decimal('rate', 8, 4); $t->decimal('min')->nullable(); $t->decimal('max')->nullable(); $t->string('fixed')->nullable(); $t->integer('status'); $t->integer('sort'); $t->timestamps(); }); Schema::create('recharge_channel_group', function (Blueprint $t) { $t->id(); $t->string('name'); $t->text('recharge_type')->nullable(); $t->text('withdraw_type')->nullable(); $t->text('activity_type')->nullable(); $t->timestamps(); }); $fixture = require dirname(__DIR__) . '/fixtures/payment_provider_channels.php'; DB::table('recharge_channel')->insert($fixture['channels']); DB::table('recharge_channel_group')->insert($fixture['groups']); foreach (['2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php', '2026_09_08_170000_add_soft_deletes_to_recharge_channel_groups.php', '2026_10_28_150000_add_recharge_channel_logo.php', '2026_10_28_160000_create_recharge_method_logos.php', '2026_11_08_120000_create_agent_payment_orders.php', '2026_11_08_170000_add_agent_withdrawal_channels.php', '2026_11_09_120000_add_fee_snapshots_to_payment_orders.php', '2026_11_09_130000_add_fee_fixed_to_recharge_channel.php'] as $file) { (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up(); } } /** 用 payment_orders 的完整字段替换简化表(三方下单/回调会按 order_no、pay_no 查会员订单)。 */ private function fullPaymentOrders(): void { Schema::drop('payment_orders'); Schema::create('payment_orders', function (Blueprint $t) { $t->id(); $t->tinyInteger('type'); $t->string('order_no')->unique(); $t->string('member_id'); $t->decimal('amount', 18, 2); $t->decimal('fee', 18, 4)->default(0); $t->string('channel')->nullable(); $t->decimal('fee_rate', 10, 6)->default(0); $t->decimal('fee_fixed', 18, 4)->default(0); $t->string('bank_name')->nullable(); $t->integer('payment_type')->nullable(); $t->tinyInteger('status')->default(0); $t->string('state')->default('0'); $t->string('callback_url')->nullable(); $t->text('callback_data')->nullable(); $t->string('pay_no')->nullable(); $t->text('pay_url')->nullable(); $t->text('pay_data')->nullable(); $t->string('image')->nullable(); $t->tinyInteger('is_send')->default(0); $t->string('remark')->nullable(); $t->text('recharge_bonus_snapshot')->nullable(); $t->timestamps(); }); } private function agent(int $type = Agent::TYPE_JOINT): Agent { static $number = 0; return $this->agents->create([ 'username' => 'agent' . ++$number, 'password' => 'old-password', 'withdraw_password' => 'withdraw-old', 'type' => $type, ]); } private function token(Agent $agent): string { $plain = 'test-token-' . $agent->id; AgentToken::query()->create(['agent_id' => $agent->id, 'token_hash' => hash('sha256', $plain), 'expires_at' => now()->addDay(), 'last_used_at' => now(), 'ip' => '', 'device' => 'pc']); return $plain; } }