app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']])); Facade::setFacadeApplication($this->app); $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']); $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema()); $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $this->app->instance('validator', new Factory($translator, $this->app)); $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class { public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); } }); Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); }); $db->schema()->create('share_settings', function (Blueprint $t) { $t->id(); $t->integer('enabled')->default(0); $t->integer('benefit_enabled')->default(0); $t->string('share_domain')->default(''); $t->decimal('valid_member_min_deposit', 20, 2)->default(20); $t->decimal('non_seller_threshold', 24, 10)->default(200); $t->decimal('tier_one_rate', 8, 4)->default(4); $t->decimal('tier_two_rate', 8, 4)->default(6); $t->integer('operator_id')->nullable(); $t->string('operator_name')->default(''); $t->timestamps(); }); $db->schema()->create('users', function (Blueprint $t) { $t->id(); $t->string('member_id')->unique(); $t->string('account'); $t->string('first_name'); $t->timestamps(); }); $db->schema()->create('wallets', function (Blueprint $t) { $t->id(); $t->string('member_id')->unique(); $t->decimal('available_balance', 24, 10)->default(0); $t->timestamps(); }); $db->schema()->create('balance_logs', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->string('change_type'); $t->integer('type')->default(1); foreach (['amount', 'before_balance', 'after_balance'] as $field) $t->decimal($field, 24, 10); $t->integer('related_id')->nullable(); $t->text('remark')->nullable(); $t->timestamps(); }); $db->schema()->create('operation_audits', function (Blueprint $t) { $t->id(); $t->string('resource_type'); $t->integer('resource_id')->nullable(); $t->string('action'); $t->json('changes'); $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent(); }); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_24_120000_create_member_sharing_system.php')->up(); $request = Request::create('/admin/shareEarning/settings', 'GET'); $request->user = (object)['id' => 8, 'username' => 'admin']; $this->app->instance('request', $request); $this->admin = new ShareAdminService(); $this->claims = new ShareClaimAuditService(); } protected function tearDown(): void { BalanceLogService::$MODEL = BalanceLog::class; Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function config(array $extra = []): array { return array_replace(['enabled' => 1, 'benefit_enabled' => 1, 'share_domain' => 'https://PC.example.com/', 'valid_member_min_deposit' => '20.00', 'valid_member_min_effective_bet' => '100.00'], $extra); } private function initialized(): void { DB::table('share_system_state')->insert(['key' => 'initialized_at', 'value' => '2026-10-24 18:00:00']); } private function fails(callable $callback, string $contains): void { try { $callback(); $this->fail('Expected validation rejection'); } catch (ValidationException $e) { $this->assertStringContainsString($contains, $e->validator->errors()->first()); } } public function test_explicit_setup_and_initializer_gate_enablement(): void { $this->assertNull($this->admin->settings()['activation_date']); $this->fails(fn () => $this->admin->saveSettings($this->config()), '初始化'); $this->assertSame(0, DB::table('share_settings')->count()); $this->initialized(); $incomplete = $this->config(); unset($incomplete['valid_member_min_effective_bet']); $this->fails(fn () => $this->admin->saveSettings($incomplete), 'required_if'); $this->fails(fn () => $this->admin->saveSettings($this->config(['tier_one_rate' => 4])), '已移除'); $this->fails(fn () => $this->admin->saveSettings($this->config(['activation_date' => '2020-01-01'])), '不可指定'); $this->fails(fn () => $this->admin->saveSettings($this->config(['share_domain' => 'https://pc.example/a'])), '源地址'); $data = $this->admin->saveSettings($this->config()); $this->assertSame('2026-10-25', $data['activation_date']); $this->assertSame('https://pc.example.com', $data['share_domain']); $this->assertSame(0, $data['current_policy']['benefit_enabled']); $this->assertSame(1, $data['pending_policy']['benefit_enabled']); $this->assertArrayNotHasKey('tier_one_rate', $data); $this->assertArrayNotHasKey('indirect_bonus_rate', $data); $this->assertArrayNotHasKey('indirect_bonus_rate', $data['current_policy']); $this->assertArrayNotHasKey('indirect_bonus_rate', $data['pending_policy']); $this->assertSame(12, count($data['pending_policy']['rules']['third:1'])); } public function test_pending_policy_edits_preserve_past_policy_and_activation_date(): void { $this->initialized(); $first = $this->admin->saveSettings($this->config()); $pendingId = $first['pending_policy']['id']; $this->admin->saveRateRules('third:1', [['min_valid_members' => 1, 'min_performance' => '1000000', 'rate' => '0.25']]); $again = $this->admin->saveSettings($this->config(['valid_member_min_effective_bet' => '250'])); $this->assertSame($pendingId, $again['pending_policy']['id']); $this->assertSame('0.2500', $again['pending_policy']['rules']['third:1'][0]['rate']); Carbon::setTestNow(Carbon::parse('2026-10-25 01:00:00', 'Asia/Shanghai')); $before = (array)DB::table('share_policies')->where('id', $pendingId)->first(); $this->admin->saveSettings($this->config(['benefit_enabled' => 0])); $this->admin->saveRateRules('third:2', [['min_valid_members' => 0, 'min_performance' => 10, 'rate' => '0.50']]); $this->assertSame($before, (array)DB::table('share_policies')->where('id', $pendingId)->first()); $this->assertSame(0, $this->admin->settings()['pending_policy']['benefit_enabled']); $this->admin->saveSettings($this->config()); $this->assertSame('2026-10-25', $this->admin->settings()['activation_date']); $this->assertSame(3, DB::table('share_policies')->count()); } public function test_retired_indirect_coefficient_is_rejected_even_when_null_without_saving(): void { $this->initialized(); foreach ([null, 0, '30.0000'] as $value) { $this->fails(fn () => $this->admin->saveSettings($this->config(['indirect_bonus_rate' => $value])), '已移除'); } $this->assertSame(0, DB::table('share_settings')->count()); $this->assertSame(1, DB::table('share_policies')->count()); $this->assertSame(0, DB::table('operation_audits')->count()); $request = Request::create('/admin/shareEarning/settings', 'POST', $this->config(['indirect_bonus_rate' => '30'])); $this->app->instance('request', $request); $body = (new ShareEarning())->saveSettings($this->admin)->getData(true); $this->assertSame(-3, $body['code']); $this->assertStringContainsString('已移除', $body['msg']); $this->assertSame(0, DB::table('share_settings')->count()); } public function test_rule_edits_need_no_coefficient_and_preserve_archived_policy_values(): void { $this->initialized(); DB::table('share_settings')->insert(['indirect_bonus_rate' => '25.0000']); DB::table('share_policies')->update(['indirect_bonus_rate' => '30.0000']); $archived = (array) DB::table('share_policies')->where('effective_date', '1970-01-01')->first(); $settings = $this->admin->saveSettings($this->config()); $this->assertArrayNotHasKey('indirect_bonus_rate', $settings); $this->assertArrayNotHasKey('indirect_bonus_rate', $settings['current_policy']); $this->assertArrayNotHasKey('indirect_bonus_rate', $settings['pending_policy']); $this->assertNull(DB::table('share_policies')->where('id', $settings['pending_policy']['id'])->value('indirect_bonus_rate')); $this->assertSame('25.0000', bcadd((string) DB::table('share_settings')->value('indirect_bonus_rate'), '0', 4)); $model = ShareSetting::query()->firstOrFail(); $this->assertArrayNotHasKey('indirect_bonus_rate', $model->getCasts()); $this->assertArrayNotHasKey('indirect_bonus_rate', $model->toArray()); $rules = $this->admin->saveRateRules('third:1', [['min_valid_members' => 0, 'min_performance' => 0, 'rate' => '10']]); $this->assertSame('10.0000', $rules['categories'][0]['pending_rules'][0]['rate']); $this->admin->saveSettings($this->config(['valid_member_min_effective_bet' => '250'])); $this->admin->saveLevel(['level' => 2, 'name' => '二级', 'image' => '', 'incremental_commission' => '10']); $settings = $this->admin->settings(); $this->assertSame('10.0000', $settings['pending_policy']['rules']['third:1'][0]['rate']); $this->assertSame('250.00', $settings['pending_policy']['valid_member_min_effective_bet']); $this->assertArrayNotHasKey('indirect_bonus_rate', $settings['pending_policy']); $this->assertNull(DB::table('share_policies')->where('id', $settings['pending_policy']['id'])->value('indirect_bonus_rate')); $this->assertSame($archived, (array) DB::table('share_policies')->where('id', $archived['id'])->first()); Carbon::setTestNow(Carbon::parse('2026-10-25 01:00:00', 'Asia/Shanghai')); $this->assertSame('10.0000', $this->admin->rateRules('third:1')['categories'][0]['current_rules'][0]['rate']); $this->assertArrayNotHasKey('indirect_bonus_rate', $this->admin->settings()['current_policy']); } public function test_diagnostic_and_backfill_output_hide_archived_coefficient_without_writes(): void { $schema = DB::connection()->getSchemaBuilder(); $schema->table('users', function (Blueprint $t) { $t->string('user_code')->default(''); $t->string('agent_user_code')->nullable(); $t->integer('from')->default(1); }); $schema->create('config', function (Blueprint $t) { $t->id(); $t->string('field'); $t->string('val'); }); $schema->create('share_commission_records', function (Blueprint $t) { $t->id(); $t->date('stat_date'); $t->integer('status'); }); DB::table('config')->insert(['field' => 'recharge_rate', 'val' => '2.5000']); DB::table('share_settings')->insert(['indirect_bonus_rate' => '30', 'valid_member_min_effective_bet' => '100']); $before = (array) DB::table('share_settings')->first(); $data = (new ShareCommissionDiagnostics())->inspect(null); $this->assertArrayNotHasKey('indirect_bonus_rate', $data); $this->assertEquals('100', $data['valid_member_min_effective_bet']); $command = new BackfillShareCommissions(); $command->setLaravel($this->app); $tester = new CommandTester($command); $tester->execute(['--diagnose' => true, '--all' => true]); $this->assertSame(0, $tester->getStatusCode(), $tester->getDisplay()); $this->assertStringNotContainsString('indirect_bonus_rate', $tester->getDisplay()); $this->assertStringContainsString('valid_member_min_effective_bet: 100', $tester->getDisplay()); $this->assertSame($before, (array) DB::table('share_settings')->first()); $this->assertSame(0, DB::table('share_commission_records')->count()); $this->assertSame(0, DB::table('balance_logs')->count()); } public function test_deposit_precision_and_display_switch_do_not_silently_change_money_configuration(): void { $this->initialized(); $this->fails(fn () => $this->admin->saveSettings($this->config(['valid_member_min_deposit' => '20.123'])), 'regex'); $this->assertSame(0, DB::table('share_settings')->count()); $this->assertSame(1, DB::table('share_policies')->count()); $saved = $this->admin->saveSettings($this->config(['enabled' => 0, 'valid_member_min_deposit' => '20.12'])); $this->assertSame(0, $saved['enabled']); $this->assertSame(1, $saved['benefit_enabled']); $this->assertSame(1, $saved['pending_policy']['benefit_enabled']); $this->assertSame('20.12', $saved['pending_policy']['valid_member_min_deposit']); $this->assertSame('20.12', $saved['valid_member_min_deposit']); $this->assertSame('20.1200000000', bcadd((string)DB::table('share_policies')->where('effective_date', '2026-10-25')->value('valid_member_min_deposit'), '0', 10)); } public function test_rules_are_ordered_by_threshold_and_invalid_ladders_are_rejected_without_pending_writes(): void { $this->admin->saveRateRules('third:1', [ ['min_valid_members' => 2, 'min_performance' => '100', 'rate' => '0.30'], ['min_valid_members' => 1, 'min_performance' => '100', 'rate' => '0.20'], ['min_valid_members' => 0, 'min_performance' => '10', 'rate' => '0.10'], ]); $rules = $this->admin->rateRules('third:1')['categories'][0]['pending_rules']; $this->assertSame([0, 1, 2], array_column($rules, 'min_valid_members')); $this->assertSame(['10.00', '100.00', '100.00'], array_column($rules, 'min_performance')); $before = (array)DB::table('share_policies')->where('effective_date', '2026-10-25')->first(); $this->fails(fn () => $this->admin->saveRateRules('third:1', [ ['min_valid_members' => 1, 'min_performance' => '100.0', 'rate' => '0.20'], ['min_valid_members' => 1, 'min_performance' => '100.00', 'rate' => '0.30'], ]), '不可重复'); $this->fails(fn () => $this->admin->saveRateRules('third:1', [ ['min_valid_members' => 2, 'min_performance' => '100', 'rate' => '0.20'], ['min_valid_members' => 1, 'min_performance' => '200', 'rate' => '0.30'], ]), '人数门槛不可降低'); $this->fails(fn () => $this->admin->saveRateRules('third:1', [ ['min_valid_members' => 1, 'min_performance' => '100', 'rate' => '0.30'], ['min_valid_members' => 2, 'min_performance' => '200', 'rate' => '0.20'], ]), '返佣比例不可降低'); $this->assertSame($before, (array)DB::table('share_policies')->where('effective_date', '2026-10-25')->first()); $this->assertSame(1, DB::table('operation_audits')->count()); } public function test_levels_sum_increments_and_never_downgrade_members(): void { DB::table('share_members')->insert(['user_id' => 7, 'level' => 1, 'bound_at' => '2026-10-24 00:00:00']); DB::table('share_accounts')->insert(['user_id' => 7, 'earned_amount' => 25]); $this->admin->saveLevel(['level' => 2, 'name' => '二级', 'image' => '', 'incremental_commission' => 10]); $this->admin->saveLevel(['level' => 3, 'name' => '三级', 'image' => '/three.png', 'incremental_commission' => 20]); $this->assertSame(2, (int)DB::table('share_members')->value('level')); $levels = $this->admin->levels()['data']; $this->assertSame('30.00', $levels[2]['cumulative_commission']); $this->admin->saveLevel(['id' => $levels[1]['id'], 'level' => 2, 'name' => '二级', 'image' => '', 'incremental_commission' => 100]); $this->assertSame(2, (int)DB::table('share_members')->value('level')); $this->fails(fn () => $this->admin->deleteLevel($levels[1]['id']), '已有会员'); $this->admin->deleteLevel($levels[2]['id']); $this->assertCount(2, $this->admin->levels()['data']); } private function pending(string $earned = '10.019', string $amount = '10.01'): int { DB::table('users')->insert(['id' => 7, 'member_id' => 'M7', 'account' => 'member7', 'first_name' => '会员']); DB::table('wallets')->insert(['member_id' => 'M7', 'available_balance' => 20]); DB::table('share_accounts')->insert(['user_id' => 7, 'earned_amount' => $earned, 'paid_amount' => 0, 'reserved_amount' => $amount]); return DB::table('share_claims')->insertGetId(['user_id' => 7, 'request_id' => 'req-1', 'active_user_id' => 7, 'amount' => $amount, 'status' => 'pending', 'created_at' => '2026-10-24 12:00:00', 'updated_at' => '2026-10-24 12:00:00']); } public function test_approval_credits_member_wallet_once_and_retains_fractional_remainder(): void { $id = $this->pending(); $approved = $this->claims->audit($id, 'approve', 8, '已核对'); $this->assertSame('approved', $approved['status']); $this->assertSame('0.00', $approved['commission_account']['spendable_amount']); $this->assertSame('10.01', $approved['commission_account']['paid_amount']); $this->assertEquals('30.01', DB::table('wallets')->value('available_balance')); $this->assertEquals('0.0090000000', bcsub((string)DB::table('share_accounts')->value('earned_amount'), (string)DB::table('share_accounts')->value('paid_amount'), 10)); $this->assertNull(DB::table('share_claims')->value('active_user_id')); $this->claims->audit($id, 'approve', 8, '重复'); $this->assertSame(1, DB::table('balance_logs')->count()); $this->assertSame(1, DB::table('operation_audits')->count()); $this->assertSame('分享佣金领取', DB::table('balance_logs')->value('change_type')); $this->assertSame(8, (int)DB::table('operation_audits')->value('operator_id')); $this->fails(fn () => $this->claims->audit($id, 'reject', 8), '不可更改'); } public function test_recomputed_insufficiency_blocks_approval_and_rejection_releases_full_reservation(): void { $id = $this->pending('5.00', '10.01'); $this->assertFalse($this->claims->detail($id)['can_approve']); $this->fails(fn () => $this->claims->audit($id, 'approve', 8), '重新申请'); $this->assertSame('pending', DB::table('share_claims')->value('status')); $this->assertEquals(20, DB::table('wallets')->value('available_balance')); $rejected = $this->claims->audit($id, 'reject', 8, '请重新申请'); $this->assertSame('5.00', $rejected['commission_account']['spendable_amount']); $this->assertSame(0, DB::table('balance_logs')->count()); $this->claims->audit($id, 'reject', 8); $this->assertSame(1, DB::table('operation_audits')->count()); } public function test_audit_failure_rolls_back_wallet_log_account_and_claim(): void { $id = $this->pending(); DB::statement("CREATE TRIGGER block_operation_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit offline'); END"); try { $this->claims->audit($id, 'approve', 8); $this->fail('Expected audit write failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit offline', $e->getMessage()); } $this->assertEquals(20, DB::table('wallets')->value('available_balance')); $this->assertSame('pending', DB::table('share_claims')->value('status')); $this->assertEquals(0, DB::table('share_accounts')->value('paid_amount')); $this->assertEquals('10.01', DB::table('share_accounts')->value('reserved_amount')); $this->assertSame(0, DB::table('balance_logs')->count()); } public function test_admin_controller_envelopes_and_filtered_lists(): void { $id = $this->pending(); $this->assertSame(0, (new ShareEarning())->settings($this->admin)->getData(true)['code']); $request = Request::create('/admin/shareEarning/claims/audit', 'POST', ['id' => $id, 'action' => 'approve']); $request->user = (object)['id' => 8, 'username' => 'admin']; $this->app->instance('request', $request); $body = (new SharingManagement())->auditClaim($this->claims)->getData(true); $this->assertSame(0, $body['code']); $this->assertSame('approved', $body['data']['status']); $list = $this->claims->list(['status' => 'approved', 'account' => 'member7', 'start_date' => '2026-10-24', 'end_date' => '2026-10-24']); $this->assertSame(1, $list['total']); $this->assertSame('10.01', $list['data'][0]['amount']); $this->assertSame(0, $this->claims->list(['status' => 'pending'])['total']); } public function test_database_errors_do_not_expose_sql_in_admin_responses(): void { $this->app->instance('log', new \Psr\Log\NullLogger()); DB::statement('DROP TABLE bot_share_levels'); $body = (new SharingManagement())->levels($this->admin)->getData(true); $this->assertSame(-3, $body['code']); $this->assertStringContainsString('暂不可用', $body['msg']); $this->assertStringNotContainsString('SQLSTATE', $body['msg']); $this->assertStringNotContainsString('share_levels', $body['msg']); } public function test_money_configuration_cannot_lose_subcent_values_during_frontend_roundtrip(): void { $this->initialized(); $this->fails(fn () => $this->admin->saveSettings($this->config(['valid_member_min_effective_bet'=>'0.001'])), 'regex'); $this->fails(fn () => $this->admin->saveRateRules('third:2', [['min_valid_members'=>0,'min_performance'=>'0.001','rate'=>'0.1']]), 'regex'); $this->fails(fn () => $this->admin->saveLevel(['level'=>2,'name'=>'二级','image'=>'','incremental_commission'=>'0.001']), 'regex'); $this->assertSame(1, DB::table('share_policies')->count()); $this->assertSame(1, DB::table('share_levels')->count()); } }