AdminRealtimeSummaryTest.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\Online;
  4. use App\Http\Controllers\admin\PaymentOrder;
  5. use App\Http\Controllers\admin\User as AdminUser;
  6. use App\Services\AdminRealtime\AdminRealtimeFeed;
  7. use App\Services\AdminRealtime\AdminRealtimeSummaries;
  8. use App\Services\OnlineUserService;
  9. use App\Services\UserService;
  10. use Carbon\Carbon;
  11. use Illuminate\Cache\ArrayStore;
  12. use Illuminate\Cache\Repository as Cache;
  13. use Illuminate\Config\Repository;
  14. use Illuminate\Database\Capsule\Manager;
  15. use Illuminate\Database\Eloquent\Model;
  16. use Illuminate\Database\Schema\Blueprint;
  17. use Illuminate\Events\Dispatcher;
  18. use Illuminate\Foundation\Application;
  19. use Illuminate\Http\Request;
  20. use Illuminate\Support\Facades\DB;
  21. use Illuminate\Support\Facades\Facade;
  22. use Illuminate\Support\Facades\Schema;
  23. use Illuminate\Translation\ArrayLoader;
  24. use Illuminate\Translation\Translator;
  25. use Illuminate\Validation\Factory;
  26. use PHPUnit\Framework\TestCase;
  27. class AdminRealtimeSummaryTest extends TestCase
  28. {
  29. private Application $app;
  30. private Carbon $clock;
  31. protected function setUp(): void
  32. {
  33. Facade::clearResolvedInstances(); Model::clearBootedModels();
  34. $this->clock = Carbon::now('Asia/Shanghai'); Carbon::setTestNow($this->clock);
  35. $this->app = new Application(dirname(__DIR__, 2));
  36. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'],
  37. 'admin_realtime' => ['enabled' => true, 'summary_scan_seconds' => 5, 'online_scan_seconds' => 15]]));
  38. $this->app->instance('events', new Dispatcher($this->app));
  39. $this->app->instance('cache', new Cache(new ArrayStore()));
  40. $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
  41. $this->app->instance('request', Request::create('/admin/realtime/replay'));
  42. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  43. $this->app->instance('log', new class {
  44. public array $records = [];
  45. public function channel($name) { return $this; }
  46. public function error($message, $context = []) { $this->records[] = [$message, $context]; }
  47. });
  48. Facade::setFacadeApplication($this->app);
  49. $db = new Manager($this->app);
  50. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  51. $db->setEventDispatcher($this->app['events']); $db->setAsGlobal(); $db->bootEloquent();
  52. $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn() => $db->schema());
  53. // 只在隔离 SQLite 中提供本服务所用排序规则,执行原始统计 SQL 而非替换统计服务。
  54. DB::connection()->getPdo()->sqliteCreateCollation('utf8mb4_general_ci', 'strcasecmp');
  55. foreach (['2026_10_31_160000_create_admin_realtime_events.php', '2026_10_31_210000_add_seq_to_admin_realtime_events.php',
  56. '2026_11_01_000000_add_delivery_state_to_admin_realtime_events.php'] as $file) {
  57. (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
  58. }
  59. Schema::create('users', function (Blueprint $t) {
  60. $t->id(); $t->string('user_id'); $t->string('member_id'); $t->integer('from')->default(1); $t->integer('last_active_time')->nullable();
  61. $t->timestamp('created_at')->nullable(); $t->integer('level')->nullable(); $t->string('agent_user_code')->nullable();
  62. });
  63. Schema::create('user_session', function (Blueprint $t) {
  64. $t->id(); $t->string('user_id'); $t->integer('expire_time'); $t->integer('create_time'); $t->integer('update_time')->nullable();
  65. });
  66. Schema::create('user_login', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->string('platform'); $t->integer('status')->default(1); });
  67. foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
  68. Schema::create($table, function (Blueprint $t) use ($table) {
  69. $t->id(); $t->integer('status'); $t->dateTime('created_at');
  70. if ($table === 'payment_orders') $t->integer('type');
  71. });
  72. }
  73. Schema::create('share_claims', function (Blueprint $t) { $t->id(); $t->string('status'); });
  74. DB::table('users')->insert(['user_id' => 'account1', 'member_id' => '1001', 'last_active_time' => $this->clock->timestamp]);
  75. DB::table('user_login')->insert(['user_id' => 'account1', 'platform' => 'iOS', 'status' => 1]);
  76. $now = date('Y-m-d H:i:s');
  77. DB::table('recharges')->insert(['status' => 0, 'created_at' => $now]);
  78. DB::table('withdraws')->insert(['status' => 0, 'created_at' => $now]);
  79. foreach ([[3, 0], [3, 1], [3, 5], [1, 0], [1, 1], [4, 0], [2, 0], [1, 6]] as [$type, $status]) {
  80. DB::table('payment_orders')->insert(compact('type', 'status') + ['created_at' => $now]);
  81. }
  82. DB::table('share_claims')->insert([['status' => 'pending'], ['status' => 'pending'], ['status' => 'approved']]);
  83. }
  84. protected function tearDown(): void
  85. {
  86. DB::disconnect(); Facade::clearResolvedInstances(); Carbon::setTestNow(); parent::tearDown();
  87. }
  88. private function tick(int $seconds): array
  89. {
  90. Carbon::setTestNow($this->clock->copy()->addSeconds($seconds));
  91. $batch = AdminRealtimeFeed::next(AdminRealtimeFeed::startCursor());
  92. AdminRealtimeFeed::acknowledge($batch['ids']);
  93. return $batch['events'];
  94. }
  95. public function test_initial_snapshots_match_http_responses_and_support_replay(): void
  96. {
  97. $events = $this->tick(0);
  98. $this->assertSame(['admin.stats.platform_count', 'admin.stats.unprocessed'], array_column($events, 'channel'));
  99. $this->assertSame(['stats.updated', 'stats.updated'], array_column($events, 'event'));
  100. $this->assertSame([['scope' => 'all'], ['scope' => 'all']], array_column($events, 'key'));
  101. $this->assertSame((new Online())->platformCount()->getData(true)['data'], $events[0]['data']);
  102. $this->assertSame((new PaymentOrder())->unProcessed()->getData(true)['data'], $events[1]['data']);
  103. $this->assertSame(['usdtRecharge' => 1, 'rgRecharge' => 3, 'rmbRecharge' => 2, 'usdtWithdraw' => 1,
  104. 'rgWithdraw' => 1, 'rmbWithdraw' => 1, 'shareClaim' => 2], $events[1]['data']);
  105. $replay = AdminRealtimeFeed::replay(0, ['admin.stats.unprocessed']);
  106. $this->assertSame(1, count($replay['events']));
  107. $this->assertSame($events[1]['data'], $replay['events'][0]['data']);
  108. $this->assertSame([], $this->tick(6), '统计值未变,不重复推送');
  109. }
  110. public function test_platform_switch_is_detected_even_when_online_user_ids_do_not_change(): void
  111. {
  112. $this->tick(0); $before = OnlineUserService::onlineUserIds();
  113. DB::table('user_login')->insert(['user_id' => 'account1', 'platform' => 'Android', 'status' => 1]);
  114. $events = $this->tick(6);
  115. $this->assertSame($before, OnlineUserService::onlineUserIds());
  116. $this->assertCount(1, $events);
  117. $this->assertSame('admin.stats.platform_count', $events[0]['channel']);
  118. $this->assertSame(0, $events[0]['data']['apple']);
  119. $this->assertSame(1, $events[0]['data']['android']);
  120. $this->assertSame([], $this->tick(12));
  121. DB::table('users')->update(['last_active_time' => $this->clock->timestamp - 300]);
  122. $events = $this->tick(18);
  123. $summary = array_values(array_filter($events, fn($e) => $e['channel'] === 'admin.stats.platform_count'));
  124. $this->assertSame(0, $summary[0]['data']['total'], '活跃窗口过期时归零');
  125. }
  126. public function test_online_filter_and_offline_push_use_the_same_five_minute_window(): void
  127. {
  128. Schema::create('wallets', function (Blueprint $t) { $t->id(); $t->string('member_id'); });
  129. Schema::create('balance_logs', function (Blueprint $t) {
  130. $t->id(); $t->string('member_id'); $t->integer('type'); $t->string('change_type'); $t->decimal('amount');
  131. });
  132. $now = $this->clock->timestamp;
  133. DB::table('users')->where('id', 1)->update(['last_active_time' => $now - 320]);
  134. DB::table('users')->insert([
  135. ['user_id' => 'account2', 'member_id' => '1002', 'last_active_time' => $now - 300, 'from' => 1],
  136. ['user_id' => 'account3', 'member_id' => '1003', 'last_active_time' => null, 'from' => 1],
  137. ['user_id' => 'guest', 'member_id' => '1004', 'last_active_time' => $now, 'from' => 2],
  138. ['user_id' => 'account5', 'member_id' => '1005', 'last_active_time' => $now - 299, 'from' => 1],
  139. ]);
  140. DB::table('user_session')->insert([
  141. ['user_id' => 'account1', 'expire_time' => $now + 86400, 'create_time' => $now - 3600, 'update_time' => $now - 299],
  142. ['user_id' => '1002', 'expire_time' => $now + 86400, 'create_time' => $now - 300, 'update_time' => null],
  143. ['user_id' => 'account3', 'expire_time' => $now, 'create_time' => $now - 10, 'update_time' => $now],
  144. ]);
  145. $all = UserService::paginate();
  146. $this->assertSame(5, $all['total']);
  147. $this->assertSame(['1005' => true, '1004' => false, '1003' => false, '1002' => false, '1001' => true], $all['data']->pluck('is_online', 'member_id')->all());
  148. $this->app->instance('validator', new Factory($this->app['translator'], $this->app));
  149. Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  150. $this->app->instance('request', Request::create('/admin/user?is_online=1&limit=1'));
  151. $online = (new AdminUser())->index()->getData(true)['data'];
  152. $this->assertSame(2, $online['total']);
  153. $this->assertCount(1, $online['data']);
  154. $this->assertTrue($online['data'][0]['is_online']);
  155. $this->assertSame(3, UserService::paginate(['is_online' => '0'])['total']);
  156. $this->assertSame(0, UserService::paginate(['is_online' => '1', 'member_id' => '1002'])['total']);
  157. $this->assertEqualsCanonicalizing([1, 5], OnlineUserService::onlineUserIds());
  158. $this->assertSame(2, OnlineUserService::platformCounts()['total']);
  159. $this->tick(0);
  160. Carbon::setTestNow($this->clock->copy()->addSecond());
  161. $this->assertSame(0, UserService::paginate(['is_online' => '1'])['total'], '满五分钟即离线');
  162. $events = array_values(array_filter($this->tick(16), fn ($event) => $event['event'] === 'user.offline'));
  163. $this->assertEqualsCanonicalizing(['1001', '1005'], array_column(array_column($events, 'key'), 'member_id'));
  164. }
  165. public function test_member_amount_columns_sort_before_pagination(): void
  166. {
  167. DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', max(...), 2);
  168. Schema::create('wallets', function (Blueprint $t) {
  169. $t->id(); $t->string('member_id'); $t->decimal('available_balance');
  170. $t->decimal('wager_required'); $t->decimal('wager_completed');
  171. });
  172. Schema::create('balance_logs', function (Blueprint $t) {
  173. $t->id(); $t->string('member_id'); $t->integer('type'); $t->string('change_type'); $t->decimal('amount');
  174. });
  175. DB::table('users')->insert([
  176. ['user_id' => 'account2', 'member_id' => '1002'],
  177. ['user_id' => 'account3', 'member_id' => '1003'],
  178. ]);
  179. DB::table('wallets')->insert([
  180. ['member_id' => '1001', 'available_balance' => 100, 'wager_required' => 50, 'wager_completed' => 40],
  181. ['member_id' => '1002', 'available_balance' => 2, 'wager_required' => 100, 'wager_completed' => 0],
  182. ]);
  183. $this->app->instance('validator', new Factory($this->app['translator'], $this->app));
  184. Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  185. foreach (['available_balance' => ['1002', '1001'], 'wager_remaining' => ['1001', '1002']] as $field => [$lower, $higher]) {
  186. foreach ([['asc', 2, $lower], ['desc', 1, $higher]] as [$order, $page, $memberId]) {
  187. $filters = ['sort_field' => $field, 'sort_order' => $order, 'page' => $page, 'limit' => 1];
  188. $this->app->instance('request', Request::create('/admin/user', 'GET', $filters));
  189. $body = (new AdminUser())->index()->getData(true);
  190. $this->assertSame(0, $body['code'], $body['msg']);
  191. $this->assertSame(3, $body['data']['total']);
  192. $this->assertSame([$memberId], array_column($body['data']['data'], 'member_id'));
  193. }
  194. }
  195. }
  196. public function test_pending_badges_track_raw_writes_claim_audits_and_aged_orders(): void
  197. {
  198. $this->tick(0);
  199. // 不经过 Eloquent 或事件登记,模拟共享数据库另一应用的写入。
  200. DB::table('payment_orders')->where('type', 3)->update(['status' => 2]);
  201. DB::table('share_claims')->where('id', 1)->update(['status' => 'rejected']);
  202. DB::table('recharges')->update(['created_at' => date('Y-m-d H:i:s', time() - 90000)]);
  203. $events = $this->tick(6);
  204. $this->assertCount(1, $events);
  205. $this->assertSame('admin.stats.unprocessed', $events[0]['channel']);
  206. $this->assertSame(0, $events[0]['data']['rgRecharge']);
  207. $this->assertSame(0, $events[0]['data']['usdtRecharge']);
  208. $this->assertSame(1, $events[0]['data']['shareClaim']);
  209. $this->assertSame((new PaymentOrder())->unProcessed()->getData(true)['data'], $events[0]['data']);
  210. $this->assertSame(8, DB::table('payment_orders')->count(), '扫描只读业务表');
  211. }
  212. public function test_failed_outbox_registration_does_not_swallow_the_snapshot_change(): void
  213. {
  214. Schema::rename('admin_realtime_events', 'unavailable_realtime_events');
  215. AdminRealtimeSummaries::scan();
  216. $this->assertNotEmpty($this->app['log']->records);
  217. Schema::rename('unavailable_realtime_events', 'admin_realtime_events');
  218. $events = $this->tick(6);
  219. $this->assertCount(2, $events, '事件表恢复后仍会登记两个初始快照');
  220. $this->assertSame([], $this->tick(12));
  221. }
  222. }