AdminRealtimeSummaryTest.php 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\Online;
  4. use App\Http\Controllers\admin\PaymentOrder;
  5. use App\Services\AdminRealtime\AdminRealtimeFeed;
  6. use App\Services\AdminRealtime\AdminRealtimeSummaries;
  7. use App\Services\OnlineUserService;
  8. use Carbon\Carbon;
  9. use Illuminate\Cache\ArrayStore;
  10. use Illuminate\Cache\Repository as Cache;
  11. use Illuminate\Config\Repository;
  12. use Illuminate\Database\Capsule\Manager;
  13. use Illuminate\Database\Eloquent\Model;
  14. use Illuminate\Database\Schema\Blueprint;
  15. use Illuminate\Events\Dispatcher;
  16. use Illuminate\Foundation\Application;
  17. use Illuminate\Http\Request;
  18. use Illuminate\Support\Facades\DB;
  19. use Illuminate\Support\Facades\Facade;
  20. use Illuminate\Support\Facades\Schema;
  21. use Illuminate\Translation\ArrayLoader;
  22. use Illuminate\Translation\Translator;
  23. use PHPUnit\Framework\TestCase;
  24. class AdminRealtimeSummaryTest extends TestCase
  25. {
  26. private Application $app;
  27. private Carbon $clock;
  28. protected function setUp(): void
  29. {
  30. Facade::clearResolvedInstances(); Model::clearBootedModels();
  31. $this->clock = Carbon::now('Asia/Shanghai'); Carbon::setTestNow($this->clock);
  32. $this->app = new Application(dirname(__DIR__, 2));
  33. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'],
  34. 'admin_realtime' => ['enabled' => true, 'summary_scan_seconds' => 5, 'online_scan_seconds' => 15]]));
  35. $this->app->instance('events', new Dispatcher($this->app));
  36. $this->app->instance('cache', new Cache(new ArrayStore()));
  37. $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
  38. $this->app->instance('request', Request::create('/admin/realtime/replay'));
  39. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  40. $this->app->instance('log', new class {
  41. public array $records = [];
  42. public function channel($name) { return $this; }
  43. public function error($message, $context = []) { $this->records[] = [$message, $context]; }
  44. });
  45. Facade::setFacadeApplication($this->app);
  46. $db = new Manager($this->app);
  47. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  48. $db->setEventDispatcher($this->app['events']); $db->setAsGlobal(); $db->bootEloquent();
  49. $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn() => $db->schema());
  50. // 只在隔离 SQLite 中提供本服务所用排序规则,执行原始统计 SQL 而非替换统计服务。
  51. DB::connection()->getPdo()->sqliteCreateCollation('utf8mb4_general_ci', 'strcasecmp');
  52. foreach (['2026_10_31_160000_create_admin_realtime_events.php', '2026_10_31_210000_add_seq_to_admin_realtime_events.php',
  53. '2026_11_01_000000_add_delivery_state_to_admin_realtime_events.php'] as $file) {
  54. (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
  55. }
  56. Schema::create('users', function (Blueprint $t) {
  57. $t->id(); $t->string('user_id'); $t->string('member_id'); $t->integer('from')->default(1); $t->integer('last_active_time');
  58. });
  59. Schema::create('user_session', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->integer('expire_time'); });
  60. Schema::create('user_login', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->string('platform'); $t->integer('status')->default(1); });
  61. foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
  62. Schema::create($table, function (Blueprint $t) use ($table) {
  63. $t->id(); $t->integer('status'); $t->dateTime('created_at');
  64. if ($table === 'payment_orders') $t->integer('type');
  65. });
  66. }
  67. Schema::create('share_claims', function (Blueprint $t) { $t->id(); $t->string('status'); });
  68. DB::table('users')->insert(['user_id' => 'account1', 'member_id' => '1001', 'last_active_time' => time()]);
  69. DB::table('user_login')->insert(['user_id' => 'account1', 'platform' => 'iOS', 'status' => 1]);
  70. $now = date('Y-m-d H:i:s');
  71. DB::table('recharges')->insert(['status' => 0, 'created_at' => $now]);
  72. DB::table('withdraws')->insert(['status' => 0, 'created_at' => $now]);
  73. foreach ([[3, 0], [3, 1], [3, 5], [1, 0], [1, 1], [4, 0], [2, 0], [1, 6]] as [$type, $status]) {
  74. DB::table('payment_orders')->insert(compact('type', 'status') + ['created_at' => $now]);
  75. }
  76. DB::table('share_claims')->insert([['status' => 'pending'], ['status' => 'pending'], ['status' => 'approved']]);
  77. }
  78. protected function tearDown(): void
  79. {
  80. DB::disconnect(); Facade::clearResolvedInstances(); Carbon::setTestNow(); parent::tearDown();
  81. }
  82. private function tick(int $seconds): array
  83. {
  84. Carbon::setTestNow($this->clock->copy()->addSeconds($seconds));
  85. $batch = AdminRealtimeFeed::next(AdminRealtimeFeed::startCursor());
  86. AdminRealtimeFeed::acknowledge($batch['ids']);
  87. return $batch['events'];
  88. }
  89. public function test_initial_snapshots_match_http_responses_and_support_replay(): void
  90. {
  91. $events = $this->tick(0);
  92. $this->assertSame(['admin.stats.platform_count', 'admin.stats.unprocessed'], array_column($events, 'channel'));
  93. $this->assertSame(['stats.updated', 'stats.updated'], array_column($events, 'event'));
  94. $this->assertSame([['scope' => 'all'], ['scope' => 'all']], array_column($events, 'key'));
  95. $this->assertSame((new Online())->platformCount()->getData(true)['data'], $events[0]['data']);
  96. $this->assertSame((new PaymentOrder())->unProcessed()->getData(true)['data'], $events[1]['data']);
  97. $this->assertSame(['usdtRecharge' => 1, 'rgRecharge' => 3, 'rmbRecharge' => 2, 'usdtWithdraw' => 1,
  98. 'rgWithdraw' => 1, 'rmbWithdraw' => 1, 'shareClaim' => 2], $events[1]['data']);
  99. $replay = AdminRealtimeFeed::replay(0, ['admin.stats.unprocessed']);
  100. $this->assertSame(1, count($replay['events']));
  101. $this->assertSame($events[1]['data'], $replay['events'][0]['data']);
  102. $this->assertSame([], $this->tick(6), '统计值未变,不重复推送');
  103. }
  104. public function test_platform_switch_is_detected_even_when_online_user_ids_do_not_change(): void
  105. {
  106. $this->tick(0); $before = OnlineUserService::onlineUserIds();
  107. DB::table('user_login')->insert(['user_id' => 'account1', 'platform' => 'Android', 'status' => 1]);
  108. $events = $this->tick(6);
  109. $this->assertSame($before, OnlineUserService::onlineUserIds());
  110. $this->assertCount(1, $events);
  111. $this->assertSame('admin.stats.platform_count', $events[0]['channel']);
  112. $this->assertSame(0, $events[0]['data']['apple']);
  113. $this->assertSame(1, $events[0]['data']['android']);
  114. $this->assertSame([], $this->tick(12));
  115. DB::table('users')->update(['last_active_time' => time() - 1900]);
  116. $events = $this->tick(18);
  117. $summary = array_values(array_filter($events, fn($e) => $e['channel'] === 'admin.stats.platform_count'));
  118. $this->assertSame(0, $summary[0]['data']['total'], '活跃窗口过期时归零');
  119. }
  120. public function test_pending_badges_track_raw_writes_claim_audits_and_aged_orders(): void
  121. {
  122. $this->tick(0);
  123. // 不经过 Eloquent 或事件登记,模拟共享数据库另一应用的写入。
  124. DB::table('payment_orders')->where('type', 3)->update(['status' => 2]);
  125. DB::table('share_claims')->where('id', 1)->update(['status' => 'rejected']);
  126. DB::table('recharges')->update(['created_at' => date('Y-m-d H:i:s', time() - 90000)]);
  127. $events = $this->tick(6);
  128. $this->assertCount(1, $events);
  129. $this->assertSame('admin.stats.unprocessed', $events[0]['channel']);
  130. $this->assertSame(0, $events[0]['data']['rgRecharge']);
  131. $this->assertSame(0, $events[0]['data']['usdtRecharge']);
  132. $this->assertSame(1, $events[0]['data']['shareClaim']);
  133. $this->assertSame((new PaymentOrder())->unProcessed()->getData(true)['data'], $events[0]['data']);
  134. $this->assertSame(8, DB::table('payment_orders')->count(), '扫描只读业务表');
  135. }
  136. public function test_failed_outbox_registration_does_not_swallow_the_snapshot_change(): void
  137. {
  138. Schema::rename('admin_realtime_events', 'unavailable_realtime_events');
  139. AdminRealtimeSummaries::scan();
  140. $this->assertNotEmpty($this->app['log']->records);
  141. Schema::rename('unavailable_realtime_events', 'admin_realtime_events');
  142. $events = $this->tick(6);
  143. $this->assertCount(2, $events, '事件表恢复后仍会登记两个初始快照');
  144. $this->assertSame([], $this->tick(12));
  145. }
  146. }