instance('config', new Repository(['app' => ['timezone' => 'Asia/Shanghai']])); 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', $db->getDatabaseManager()); $app->bind('db.schema', fn () => $db->schema()); Schema::create('users', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->integer('from')->default(1); }); DB::table('users')->insert([['id' => 1, 'member_id' => '10001', 'from' => -1], ['id' => 2, 'member_id' => '20002', 'from' => 2]]); Schema::create('lhc_order', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->string('ordernum'); $t->integer('type'); $t->integer('lottery_status'); $t->decimal('amount', 24, 10); $t->decimal('total_amount', 24, 10)->nullable(); $t->bigInteger('created_at'); $t->bigInteger('updated_at'); }); Schema::create('lhc_order_item', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->string('ordernum'); $t->integer('lottery_status'); $t->decimal('win_amount', 24, 10)->nullable(); $t->decimal('profit_and_loss', 24, 10)->nullable(); $t->bigInteger('updated_at'); }); Schema::create('sport_game_order', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->integer('status'); $t->decimal('amount', 24, 10); $t->decimal('win_amount', 24, 10)->default(0); $t->timestamps(); }); Schema::create('sport_order', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->integer('status'); $t->integer('pay_status'); $t->integer('return_status')->default(0); $t->integer('settlement_status'); $t->decimal('amount', 24, 10); $t->decimal('win_amount', 24, 10)->default(0); $t->decimal('return_amount', 24, 10)->default(0); $t->bigInteger('create_time'); $t->bigInteger('update_time'); $t->bigInteger('settlement_time'); $t->bigInteger('return_operation_time')->nullable(); }); Schema::create('third_game_orders', function (Blueprint $t) { $t->id(); $t->string('order_key'); $t->unsignedBigInteger('user_id')->nullable(); $t->string('member_id'); $t->integer('game_type'); $t->integer('status'); $t->decimal('bet_amount', 24, 10)->nullable(); $t->decimal('settled_amount', 24, 10)->nullable(); $t->string('currency'); $t->dateTime('bet_time'); $t->dateTime('last_update_time'); }); Schema::create('balance_logs', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->unsignedBigInteger('related_id'); $t->string('change_type'); $t->decimal('amount', 24, 10); }); Schema::create('share_bet_facts', function (Blueprint $t) { $t->id(); $t->string('source'); $t->string('source_id'); $t->unsignedBigInteger('user_id'); $t->string('member_id'); $t->string('category'); $t->dateTime('bet_at'); $t->date('bet_date'); $t->dateTime('settled_at')->nullable(); foreach (['bet_amount', 'net_profit', 'effective_amount'] as $col) $t->decimal($col, 24, 10); $t->boolean('settled'); $t->boolean('voided'); $t->string('currency'); $t->string('source_version'); $t->timestamps(); $t->unique(['source', 'source_id']); }); Schema::create('share_system_state', function (Blueprint $t) { $t->string('key')->primary(); $t->text('value'); $t->timestamps(); }); DB::table('share_system_state')->insert(['key' => 'relations_lock', 'value' => '1']); Schema::create('rebates', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->date('date'); $t->decimal('betting_amount', 24, 2); $t->decimal('effective_betting_amount', 24, 2); $t->decimal('profit', 24, 2)->default(0); $t->integer('status'); $t->timestamps(); }); } protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); Facade::setFacadeApplication(null); Carbon::setTestNow(); parent::tearDown(); } private function lottery(int $status = 2): void { $time = Carbon::parse('2026-10-24 00:10:00', 'Asia/Shanghai')->timestamp; DB::table('lhc_order')->insert(['id' => 10, 'member_id' => '10001', 'ordernum' => 'current-lottery', 'type' => 5, 'lottery_status' => $status, 'amount' => 100, 'total_amount' => 200, 'created_at' => $time, 'updated_at' => $time + 3600]); DB::table('lhc_order_item')->insert([ ['id' => 101, 'member_id' => '10001', 'ordernum' => 'current-lottery', 'lottery_status' => 2, 'win_amount' => 150, 'profit_and_loss' => 50, 'updated_at' => $time + 3600], ['id' => 102, 'member_id' => '10001', 'ordernum' => 'current-lottery', 'lottery_status' => 1, 'win_amount' => 0, 'profit_and_loss' => -100, 'updated_at' => $time + 3600], ]); } public function test_preview_then_sync_uses_child_bets_and_repeated_sync_is_idempotent(): void { $this->lottery(); $sync = new BetFactSynchronizer(); $this->assertSame(2, $sync->sync()['created']); $this->assertSame(0, DB::table('share_bet_facts')->count()); $this->assertSame(2, $sync->sync(null, null, true)['created']); $this->assertEquals(150, DB::table('share_bet_facts')->sum('effective_amount')); $this->assertSame('2026-10-24', DB::table('share_bet_facts')->first()->bet_date); $this->assertSame(['2026-10-24'], json_decode(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value'), true)); $again = $sync->sync(null, null, true); $this->assertSame(0, $again['created']); $this->assertSame(0, $again['updated']); $this->assertSame(2, DB::table('share_bet_facts')->count()); } public function test_refunded_orders_mark_previous_facts_void_and_preserve_unfinished_facts(): void { $this->lottery(); $sync = new BetFactSynchronizer(); $sync->sync(null, null, true); // Failed draw legacy refund is stored with loss status; its positive refund log is authoritative. DB::table('lhc_order')->where('id', 10)->update(['lottery_status' => 1, 'updated_at' => Carbon::now()->timestamp]); DB::table('balance_logs')->insert(['member_id' => '10001', 'related_id' => 10, 'change_type' => '投注退分', 'amount' => 200]); $this->assertSame(2, $sync->sync(null, null, true)['updated']); $this->assertEquals(0, DB::table('share_bet_facts')->sum('effective_amount')); $this->assertSame(2, DB::table('share_bet_facts')->where('voided', 1)->count()); DB::table('sport_game_order')->insert(['member_id' => '10001', 'status' => 0, 'amount' => 100, 'created_at' => '2026-10-24 10:00:00']); $sync->sync(null, null, true); $pending = DB::table('share_bet_facts')->where('source', 'sport_game_order')->first(); $this->assertEquals(0, $pending->effective_amount); $this->assertEquals(0, $pending->settled); } public function test_football_partial_returns_third_net_profit_and_demo_exclusion(): void { $time = Carbon::parse('2026-10-24 01:00:00', 'Asia/Shanghai')->timestamp; DB::table('sport_order')->insert(['user_id' => '10001', 'status' => 1, 'pay_status' => 1, 'settlement_status' => 1, 'amount' => 100, 'win_amount' => 0, 'return_amount' => 50, 'create_time' => $time, 'update_time' => $time + 3600, 'settlement_time' => $time + 3600]); foreach ([1 => '10001', 2 => '20002'] as $id => $member) { DB::table('third_game_orders')->insert(['user_id' => $id, 'member_id' => $member, 'order_key' => hash('sha256', (string) $id), 'game_type' => 4, 'status' => 1, 'bet_amount' => 100, 'settled_amount' => 80, 'currency' => 'CNY', 'bet_time' => '2026-10-24 01:00:00', 'last_update_time' => '2026-10-24 02:00:00']); } $result = (new BetFactSynchronizer())->sync(null, null, true); $this->assertSame([], $result['errors']); $this->assertSame(2, $result['created']); $this->assertEquals(50, DB::table('share_bet_facts')->where('source', 'sport_order')->value('effective_amount')); $this->assertEquals(80, DB::table('share_bet_facts')->where('source', 'third_game_orders')->value('effective_amount')); $this->assertSame(0, DB::table('share_bet_facts')->where('user_id', 2)->count()); DB::table('third_game_orders')->where('user_id', 1)->update(['status' => 3, 'game_type' => 0, 'bet_amount' => null, 'settled_amount' => null, 'last_update_time' => '2026-10-24 12:00:00']); $sync = (new BetFactSynchronizer())->sync(null, null, true); $this->assertSame([], $sync['errors']); $this->assertSame(1, $sync['updated']); $cancelled = DB::table('share_bet_facts')->where('source', 'third_game_orders')->first(); $this->assertEquals(0, $cancelled->effective_amount); $this->assertEquals(1, $cancelled->voided); $this->assertSame('third:4', $cancelled->category); } public function test_history_recalculation_is_preview_by_default_and_never_changes_paid_or_ambiguous_dates(): void { $this->lottery(); DB::table('rebates')->insert([ ['id' => 1, 'member_id' => '10001', 'date' => '2026-10-23', 'betting_amount' => 200, 'effective_betting_amount' => 200, 'status' => 0], ['id' => 2, 'member_id' => '10001', 'date' => '2026-10-24', 'betting_amount' => 200, 'effective_betting_amount' => 200, 'status' => 0], ['id' => 3, 'member_id' => '10001', 'date' => '2026-10-22', 'betting_amount' => 200, 'effective_betting_amount' => 200, 'status' => 1], ]); $command = new RecalculateShareRebates(); $sync = new BetFactSynchronizer(); $preview = $command->recalculate($sync); $this->assertSame(1, $preview['changed']); $this->assertSame(1, $preview['skipped']); $this->assertSame(1, $preview['paid_untouched']); $this->assertEquals(200, DB::table('rebates')->where('id', 1)->value('effective_betting_amount')); $written = $command->recalculate($sync, true); $this->assertSame(1, $written['changed']); $this->assertEquals(150, DB::table('rebates')->where('id', 1)->value('effective_betting_amount')); $this->assertEquals(200, DB::table('rebates')->where('id', 2)->value('effective_betting_amount')); $this->assertEquals(200, DB::table('rebates')->where('id', 3)->value('effective_betting_amount')); } public function test_live_rebate_uses_betting_date_and_does_not_modify_paid_effective_basis(): void { DB::table('rebates')->insert(['id' => 1, 'member_id' => '10001', 'date' => '2026-10-23', 'betting_amount' => 100, 'effective_betting_amount' => 0, 'profit' => 0, 'status' => 0]); $row = Rebate::updateProfit(['member_id' => '10001', 'date' => '2026-10-23', 'profit' => 50]); $this->assertSame(1, $row->id); RebateService::updateEffectiveBettingAmount($row, '50.00'); $this->assertEquals(50, DB::table('rebates')->value('effective_betting_amount')); DB::table('rebates')->where('id', 1)->update(['status' => 1]); RebateService::updateEffectiveBettingAmount($row, '100.00'); $this->assertEquals(50, DB::table('rebates')->value('effective_betting_amount')); } public function test_legacy_injected_bets_are_never_a_source_and_partial_refund_is_flagged_for_review(): void { Schema::create('bets', function (Blueprint $t) { $t->id(); $t->integer('status'); $t->decimal('amount', 24, 10); }); DB::table('bets')->insert(['status' => 2, 'amount' => 100000]); $this->lottery(); DB::table('balance_logs')->insert(['member_id' => '10001', 'related_id' => 10, 'change_type' => '投注退分', 'amount' => 100]); $result = (new BetFactSynchronizer())->sync(null, null, true); $this->assertSame(1, $result['scanned']); $this->assertSame(1, count($result['errors'])); $this->assertStringContainsString('refund requires review', $result['errors'][0]['reason']); $this->assertSame(0, DB::table('share_bet_facts')->count()); $this->assertNull(DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value')); } public function test_incremental_cursor_includes_new_orders_with_old_dates_and_retries_updated_sources(): void { $sync = new BetFactSynchronizer(); $sync->sync(null, null, true); DB::table('sport_game_order')->insert(['id' => 20, 'member_id' => '10001', 'status' => 2, 'amount' => 100, 'win_amount' => 150, 'created_at' => '2026-10-01 10:00:00', 'updated_at' => '2026-10-01 11:00:00']); $this->assertSame(1, $sync->sync(null, null, true)['created']); DB::table('sport_game_order')->where('id', 20)->update(['status' => 3, 'updated_at' => '2026-10-24 12:00:00']); $this->assertSame(1, $sync->sync(null, null, true)['updated']); $this->assertEquals(0, DB::table('share_bet_facts')->value('effective_amount')); $this->assertSame(['2026-10-01'], json_decode(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value'), true)); } public function test_second_child_failure_rolls_back_all_facts_dirty_dates_and_source_cursor(): void { $this->lottery(); DB::statement("CREATE TRIGGER reject_second_fact BEFORE INSERT ON bot_share_bet_facts WHEN NEW.source_id='102' BEGIN SELECT RAISE(ABORT,'second child failed'); END"); $result = (new BetFactSynchronizer())->sync(null, null, true); $this->assertSame(1, count($result['errors'])); $this->assertSame(0, $result['created']); $this->assertSame(0, DB::table('share_bet_facts')->count()); $this->assertNull(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value')); $this->assertNull(DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value')); $this->assertSame(0, DB::transactionLevel()); } public function test_write_reloads_source_after_chunk_read_and_preview_never_creates_the_guard(): void { $this->lottery(); DB::table('share_system_state')->where('key', 'relations_lock')->delete(); $preview = (new BetFactSynchronizer())->sync(); $this->assertSame(2, $preview['created']); $this->assertSame(0, DB::table('share_system_state')->count()); DB::table('share_system_state')->insert(['key' => 'relations_lock', 'value' => '1']); $changed = false; DB::listen(function ($event) use (&$changed) { if (!$changed && str_contains($event->sql, 'from "bot_lhc_order" order by "id"')) { $changed = true; DB::table('lhc_order')->where('id', 10)->update(['lottery_status' => 4, 'updated_at' => Carbon::now()->timestamp]); } }); $result = (new BetFactSynchronizer())->sync(null, null, true); $this->assertTrue($changed); $this->assertSame([], $result['errors']); $this->assertSame(2, $result['created']); $this->assertEquals(0, DB::table('share_bet_facts')->sum('effective_amount')); $this->assertSame(2, DB::table('share_bet_facts')->where('voided', 1)->count()); } public function test_untrusted_source_quarantines_only_identifiable_old_facts_and_repair_restores_them(): void { $this->lottery(); DB::table('sport_game_order')->insert(['member_id' => '10001', 'status' => 2, 'amount' => 100, 'win_amount' => 180, 'created_at' => '2026-10-24 10:00:00', 'updated_at' => '2026-10-24 11:00:00']); $sync = new BetFactSynchronizer(); $sync->sync(null, null, true); $original = DB::table('share_bet_facts')->where('source', 'lhc_order_item')->orderBy('source_id')->get(); $cursor = DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value'); DB::table('share_system_state')->where('key', 'facts_dirty_dates')->update(['value' => '[]']); DB::table('lhc_order')->where('id', 10)->update(['updated_at' => Carbon::now()->timestamp]); DB::table('balance_logs')->insert(['member_id' => '10001', 'related_id' => 10, 'change_type' => '投注退分', 'amount' => 100]); $bad = $sync->sync(null, null, true); $this->assertSame(1, count($bad['errors'])); $this->assertSame(2, $bad['errors'][0]['isolated_facts']); $this->assertSame(2, $bad['updated']); $this->assertEquals(0, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->sum('effective_amount')); $this->assertSame(2, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->where('settled', 0)->where('voided', 0)->count()); $this->assertEquals(80, DB::table('share_bet_facts')->where('source', 'sport_game_order')->value('effective_amount')); $quarantined = DB::table('share_bet_facts')->where('source', 'lhc_order_item')->orderBy('source_id')->get(); foreach ($original as $i => $old) { $this->assertEquals($old->bet_amount, $quarantined[$i]->bet_amount); $this->assertEquals($old->net_profit, $quarantined[$i]->net_profit); $this->assertNotSame($old->source_version, $quarantined[$i]->source_version); } $this->assertSame($cursor, DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value')); $this->assertSame(['2026-10-24'], json_decode(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value'), true)); DB::table('balance_logs')->where('change_type', '投注退分')->delete(); $fixed = $sync->sync(null, null, true); $this->assertSame([], $fixed['errors']); $this->assertSame(2, $fixed['updated']); $this->assertEquals(150, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->sum('effective_amount')); $this->assertSame(2, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->where('settled', 1)->count()); } }