AdminRealtimeSummaryTest.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\Home;
  4. use App\Http\Controllers\admin\Online;
  5. use App\Http\Controllers\admin\PaymentOrder;
  6. use App\Http\Controllers\admin\User as AdminUser;
  7. use App\Http\Controllers\agent\MemberController as AgentMemberController;
  8. use App\Models\Agent\Agent;
  9. use App\Services\AdminRealtime\AdminRealtimeFeed;
  10. use App\Services\AdminRealtime\AdminRealtimeSummaries;
  11. use App\Services\OnlineUserService;
  12. use App\Services\ThirdGameBalanceService;
  13. use App\Services\UserService;
  14. use Carbon\Carbon;
  15. use Illuminate\Cache\ArrayStore;
  16. use Illuminate\Cache\Repository as Cache;
  17. use Illuminate\Config\Repository;
  18. use Illuminate\Database\Capsule\Manager;
  19. use Illuminate\Database\Eloquent\Model;
  20. use Illuminate\Database\Schema\Blueprint;
  21. use Illuminate\Events\Dispatcher;
  22. use Illuminate\Foundation\Application;
  23. use Illuminate\Http\Client\Factory as HttpFactory;
  24. use Illuminate\Http\Request;
  25. use Illuminate\Support\Facades\DB;
  26. use Illuminate\Support\Facades\Facade;
  27. use Illuminate\Support\Facades\Http;
  28. use Illuminate\Support\Facades\Schema;
  29. use Illuminate\Translation\ArrayLoader;
  30. use Illuminate\Translation\Translator;
  31. use Illuminate\Validation\Factory;
  32. use PHPUnit\Framework\TestCase;
  33. class AdminRealtimeSummaryTest extends TestCase
  34. {
  35. private Application $app;
  36. private Carbon $clock;
  37. protected function setUp(): void
  38. {
  39. Facade::clearResolvedInstances(); Model::clearBootedModels();
  40. $this->clock = Carbon::now('Asia/Shanghai'); Carbon::setTestNow($this->clock);
  41. $this->app = new Application(dirname(__DIR__, 2));
  42. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'],
  43. 'admin_realtime' => ['enabled' => true, 'summary_scan_seconds' => 5, 'online_scan_seconds' => 15]]));
  44. $this->app->instance('events', new Dispatcher($this->app));
  45. $this->app->instance('cache', new Cache(new ArrayStore()));
  46. $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
  47. $this->app->instance('request', Request::create('/admin/realtime/replay'));
  48. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  49. $this->app->instance('log', new class {
  50. public array $records = [];
  51. public function channel($name) { return $this; }
  52. public function error($message, $context = []) { $this->records[] = [$message, $context]; }
  53. });
  54. Facade::setFacadeApplication($this->app);
  55. $db = new Manager($this->app);
  56. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  57. $db->setEventDispatcher($this->app['events']); $db->setAsGlobal(); $db->bootEloquent();
  58. $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn() => $db->schema());
  59. // 只在隔离 SQLite 中提供本服务所用排序规则,执行原始统计 SQL 而非替换统计服务。
  60. DB::connection()->getPdo()->sqliteCreateCollation('utf8mb4_general_ci', 'strcasecmp');
  61. foreach (['2026_10_31_160000_create_admin_realtime_events.php', '2026_10_31_210000_add_seq_to_admin_realtime_events.php',
  62. '2026_11_01_000000_add_delivery_state_to_admin_realtime_events.php'] as $file) {
  63. (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
  64. }
  65. Schema::create('users', function (Blueprint $t) {
  66. $t->id(); $t->string('user_id'); $t->string('member_id'); $t->integer('from')->default(1); $t->integer('last_active_time')->nullable();
  67. $t->timestamp('created_at')->nullable(); $t->integer('level')->nullable(); $t->string('agent_user_code')->nullable();
  68. });
  69. Schema::create('user_session', function (Blueprint $t) {
  70. $t->id(); $t->string('user_id'); $t->integer('expire_time'); $t->integer('create_time'); $t->integer('update_time')->nullable();
  71. });
  72. Schema::create('user_login', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->string('platform'); $t->integer('status')->default(1); });
  73. foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
  74. Schema::create($table, function (Blueprint $t) use ($table) {
  75. $t->id(); $t->integer('status'); $t->dateTime('created_at');
  76. if ($table === 'payment_orders') $t->integer('type');
  77. });
  78. }
  79. Schema::create('share_claims', function (Blueprint $t) { $t->id(); $t->string('status'); });
  80. DB::table('users')->insert(['user_id' => 'account1', 'member_id' => '1001', 'last_active_time' => $this->clock->timestamp]);
  81. DB::table('user_login')->insert(['user_id' => 'account1', 'platform' => 'iOS', 'status' => 1]);
  82. $now = date('Y-m-d H:i:s');
  83. DB::table('recharges')->insert(['status' => 0, 'created_at' => $now]);
  84. DB::table('withdraws')->insert(['status' => 0, 'created_at' => $now]);
  85. foreach ([[3, 0], [3, 1], [3, 5], [1, 0], [1, 1], [4, 0], [2, 0], [1, 6]] as [$type, $status]) {
  86. DB::table('payment_orders')->insert(compact('type', 'status') + ['created_at' => $now]);
  87. }
  88. DB::table('share_claims')->insert([['status' => 'pending'], ['status' => 'pending'], ['status' => 'approved']]);
  89. }
  90. protected function tearDown(): void
  91. {
  92. DB::disconnect(); Facade::clearResolvedInstances(); Carbon::setTestNow(); parent::tearDown();
  93. }
  94. private function tick(int $seconds): array
  95. {
  96. Carbon::setTestNow($this->clock->copy()->addSeconds($seconds));
  97. $batch = AdminRealtimeFeed::next(AdminRealtimeFeed::startCursor());
  98. AdminRealtimeFeed::acknowledge($batch['ids']);
  99. return $batch['events'];
  100. }
  101. public function test_initial_snapshots_match_http_responses_and_support_replay(): void
  102. {
  103. $events = $this->tick(0);
  104. $this->assertSame(['admin.stats.platform_count', 'admin.stats.unprocessed'], array_column($events, 'channel'));
  105. $this->assertSame(['stats.updated', 'stats.updated'], array_column($events, 'event'));
  106. $this->assertSame([['scope' => 'all'], ['scope' => 'all']], array_column($events, 'key'));
  107. $this->assertSame((new Online())->platformCount()->getData(true)['data'], $events[0]['data']);
  108. $this->assertSame((new PaymentOrder())->unProcessed()->getData(true)['data'], $events[1]['data']);
  109. $this->assertSame(['usdtRecharge' => 1, 'rgRecharge' => 3, 'rmbRecharge' => 2, 'usdtWithdraw' => 1,
  110. 'rgWithdraw' => 1, 'rmbWithdraw' => 1, 'shareClaim' => 2], $events[1]['data']);
  111. $replay = AdminRealtimeFeed::replay(0, ['admin.stats.unprocessed']);
  112. $this->assertSame(1, count($replay['events']));
  113. $this->assertSame($events[1]['data'], $replay['events'][0]['data']);
  114. $this->assertSame([], $this->tick(6), '统计值未变,不重复推送');
  115. }
  116. public function test_merchant_balance_push_uses_the_same_snapshot_as_the_homepage(): void
  117. {
  118. config(['third_game.api_url' => 'https://supplier.test', 'third_game.sn' => 'test-merchant',
  119. 'third_game.key' => 'test-secret', 'third_game.currency' => 'CNY']);
  120. $this->app->instance(HttpFactory::class, new HttpFactory());
  121. Http::preventStrayRequests();
  122. Http::fake(['https://supplier.test/api/server/quota' => Http::sequence()
  123. ->push(['code' => 10000, 'data' => ['CNY' => '100.125']])
  124. ->push(['code' => 10000, 'data' => ['CNY' => '100.125']])
  125. ->push(['code' => 10000, 'data' => ['CNY' => '120']])]);
  126. $service = new ThirdGameBalanceService();
  127. $channel = 'admin.stats.third_game_balance';
  128. $this->assertNull(collect($this->tick(0))->firstWhere('channel', $channel));
  129. $initialSeq = AdminRealtimeFeed::lastSeq();
  130. $this->assertNull((new Home())->thirdGameBalance($service)->getData(true)['data']['balance']);
  131. Http::assertNothingSent();
  132. $service->refreshMerchantBalance();
  133. $changed = collect($this->tick(5))->firstWhere('channel', $channel);
  134. $this->assertSame((new Home())->thirdGameBalance($service)->getData(true)['data'], $changed['data']);
  135. $this->assertSame('100.1250000000', $changed['data']['balance']);
  136. Carbon::setTestNow($this->clock->copy()->addSeconds(60));
  137. $service->refreshMerchantBalance();
  138. $this->assertNull(collect($this->tick(60))->firstWhere('channel', $channel));
  139. Carbon::setTestNow($this->clock->copy()->addSeconds(120));
  140. $service->refreshMerchantBalance();
  141. $changed = collect($this->tick(120))->firstWhere('channel', $channel);
  142. $this->assertSame('120.0000000000', $changed['data']['balance']);
  143. $replay = AdminRealtimeFeed::replay($initialSeq, [$channel]);
  144. $this->assertSame($changed['data'], $replay['events'][0]['data']);
  145. Http::assertSentCount(3);
  146. }
  147. public function test_platform_switch_is_detected_even_when_online_user_ids_do_not_change(): void
  148. {
  149. $this->tick(0); $before = OnlineUserService::onlineUserIds();
  150. DB::table('user_login')->insert(['user_id' => 'account1', 'platform' => 'Android', 'status' => 1]);
  151. $events = $this->tick(6);
  152. $this->assertSame($before, OnlineUserService::onlineUserIds());
  153. $this->assertCount(1, $events);
  154. $this->assertSame('admin.stats.platform_count', $events[0]['channel']);
  155. $this->assertSame(0, $events[0]['data']['apple']);
  156. $this->assertSame(1, $events[0]['data']['android']);
  157. $this->assertSame([], $this->tick(12));
  158. DB::table('users')->update(['last_active_time' => $this->clock->timestamp - 300]);
  159. $events = $this->tick(18);
  160. $summary = array_values(array_filter($events, fn($e) => $e['channel'] === 'admin.stats.platform_count'));
  161. $this->assertSame(0, $summary[0]['data']['total'], '活跃窗口过期时归零');
  162. }
  163. public function test_online_filter_and_offline_push_use_the_same_five_minute_window(): void
  164. {
  165. Schema::create('wallets', function (Blueprint $t) { $t->id(); $t->string('member_id'); });
  166. Schema::create('balance_logs', function (Blueprint $t) {
  167. $t->id(); $t->string('member_id'); $t->integer('type'); $t->string('change_type'); $t->decimal('amount');
  168. });
  169. $now = $this->clock->timestamp;
  170. DB::table('users')->where('id', 1)->update(['last_active_time' => $now - 320]);
  171. DB::table('users')->insert([
  172. ['user_id' => 'account2', 'member_id' => '1002', 'last_active_time' => $now - 300, 'from' => 1],
  173. ['user_id' => 'account3', 'member_id' => '1003', 'last_active_time' => null, 'from' => 1],
  174. ['user_id' => 'guest', 'member_id' => '1004', 'last_active_time' => $now, 'from' => 2],
  175. ['user_id' => 'account5', 'member_id' => '1005', 'last_active_time' => $now - 299, 'from' => 1],
  176. ]);
  177. DB::table('user_session')->insert([
  178. ['user_id' => 'account1', 'expire_time' => $now + 86400, 'create_time' => $now - 3600, 'update_time' => $now - 299],
  179. ['user_id' => '1002', 'expire_time' => $now + 86400, 'create_time' => $now - 300, 'update_time' => null],
  180. ['user_id' => 'account3', 'expire_time' => $now, 'create_time' => $now - 10, 'update_time' => $now],
  181. ]);
  182. $all = UserService::paginate();
  183. $this->assertSame(5, $all['total']);
  184. $this->assertSame(['1005' => true, '1004' => false, '1003' => false, '1002' => false, '1001' => true], $all['data']->pluck('is_online', 'member_id')->all());
  185. $this->app->instance('validator', new Factory($this->app['translator'], $this->app));
  186. Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  187. $this->app->instance('request', Request::create('/admin/user?is_online=1&limit=1'));
  188. $online = (new AdminUser())->index()->getData(true)['data'];
  189. $this->assertSame(2, $online['total']);
  190. $this->assertCount(1, $online['data']);
  191. $this->assertTrue($online['data'][0]['is_online']);
  192. $this->assertSame(3, UserService::paginate(['is_online' => '0'])['total']);
  193. $this->assertSame(0, UserService::paginate(['is_online' => '1', 'member_id' => '1002'])['total']);
  194. $this->assertEqualsCanonicalizing([1, 5], OnlineUserService::onlineUserIds());
  195. $this->assertSame(2, OnlineUserService::platformCounts()['total']);
  196. $this->tick(0);
  197. Carbon::setTestNow($this->clock->copy()->addSecond());
  198. $this->assertSame(0, UserService::paginate(['is_online' => '1'])['total'], '满五分钟即离线');
  199. $events = array_values(array_filter($this->tick(16), fn ($event) => $event['event'] === 'user.offline'));
  200. $this->assertEqualsCanonicalizing(['1001', '1005'], array_column(array_column($events, 'key'), 'member_id'));
  201. }
  202. public function test_member_list_shows_the_independent_agent_as_superior(): void
  203. {
  204. Schema::table('users', function (Blueprint $t) {
  205. $t->unsignedBigInteger('agent_id')->nullable(); $t->string('user_code')->nullable(); $t->string('account')->nullable(); $t->string('first_name')->nullable();
  206. });
  207. Schema::create('agents', function (Blueprint $t) {
  208. $t->id(); $t->string('username'); $t->string('real_name')->default(''); $t->integer('type')->default(1); $t->integer('is_test')->default(0);
  209. });
  210. Schema::create('wallets', function (Blueprint $t) {
  211. $t->id(); $t->string('member_id'); $t->decimal('available_balance')->default(0); $t->decimal('wager_required')->default(0); $t->decimal('wager_completed')->default(0);
  212. });
  213. Schema::create('balance_logs', function (Blueprint $t) {
  214. $t->id(); $t->string('member_id'); $t->integer('type'); $t->string('change_type'); $t->decimal('amount');
  215. });
  216. Schema::create('share_members', function (Blueprint $t) {
  217. $t->unsignedBigInteger('user_id')->primary(); $t->unsignedBigInteger('parent_user_id')->nullable();
  218. });
  219. DB::table('agents')->insert([
  220. ['id' => 6, 'username' => 'agent06', 'type' => 1, 'is_test' => 0],
  221. ['id' => 7, 'username' => 'holder07', 'type' => 2, 'is_test' => 1],
  222. ['id' => 8, 'username' => 'agent08', 'type' => 1, 'is_test' => 0],
  223. ]);
  224. DB::table('users')->where('member_id', '1001')->update(['agent_id' => 8, 'user_code' => 'INV1001', 'account' => 'inviter1001']);
  225. foreach ([['1002', 6, null], ['1003', 7, null], ['1004', null, "\tinv1001\t"], ['1005', 7, 'INV1001'],
  226. ['1006', 6, null], ['1007', null, 'GONE'], ['1008', null, '0'], ['1009', null, 'DUPE'], ['1010', null, 'SELF'],
  227. ['1011', null, 'TRIAL'], ['1012', null, null]] as [$member, $agent, $code]) {
  228. DB::table('users')->insert(['user_id' => 'u' . $member, 'member_id' => $member, 'agent_id' => $agent, 'agent_user_code' => $code, 'account' => 'm' . $member]);
  229. }
  230. DB::table('users')->where('member_id', '1003')->update(['user_code' => '0']);
  231. DB::table('users')->whereIn('member_id', ['1002', '1005'])->update(['user_code' => 'DUPE']);
  232. DB::table('users')->where('member_id', '1010')->update(['user_code' => 'SELF']);
  233. DB::table('users')->where('member_id', '1012')->update(['user_code' => 'TRIAL', 'from' => 2]);
  234. DB::table('share_members')->insert([
  235. ['user_id' => DB::table('users')->where('member_id', '1006')->value('id'), 'parent_user_id' => 1],
  236. ['user_id' => DB::table('users')->where('member_id', '1007')->value('id'), 'parent_user_id' => 99],
  237. ]);
  238. $rows = collect(UserService::paginate(['limit' => 15])['data'])->keyBy('member_id');
  239. $this->assertSame(['agent', '合营:agent06', 'agent06', '合营'], [$rows['1002']['superior']['type'], $rows['1002']['superior']['label'],
  240. $rows['1002']['superior']['account'], $rows['1002']['superior']['agent_type_text']]);
  241. $this->assertNull($rows['1003']['superior'], 'equity agents are not shown as superior');
  242. $this->assertSame('邀请人:inviter1001(邀请码:INV1001)', $rows['1005']['superior']['label'], 'equity agent member falls back to the inviter');
  243. $this->assertSame(['member', '邀请人:inviter1001(邀请码:INV1001)', 'INV1001'],
  244. [$rows['1004']['superior']['type'], $rows['1004']['superior']['label'], $rows['1004']['superior']['invite_code']]);
  245. $this->assertSame($rows['1004']['superior'], $rows['1006']['superior'], 'fixed parent takes precedence over the agent');
  246. $this->assertNull($rows['1007']['superior'], 'parent record is missing');
  247. $this->assertNull($rows['1008']['superior'], 'zero is a placeholder, not an invitation code');
  248. foreach (['1009', '1010', '1011'] as $memberId) {
  249. $this->assertNull($rows[$memberId]['superior'], 'invalid legacy inviter');
  250. }
  251. $canBind = $rows->pluck('can_bind_superior', 'member_id')->sortKeys();
  252. $this->assertSame([1001 => false, 1002 => false, 1003 => true, 1004 => false, 1005 => false,
  253. 1006 => false, 1007 => false, 1008 => true, 1009 => false, 1010 => false, 1011 => false, 1012 => false], $canBind->all());
  254. $this->assertSame('agent06', $rows['1002']->toArray()['system_agent']['username']);
  255. $this->app->instance('validator', new Factory($this->app['translator'], $this->app));
  256. Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  257. $request = Request::create('/agent/members');
  258. $request->attributes->set('agent', Agent::query()->findOrFail(6));
  259. $this->app->instance('request', $request);
  260. $response = (new AgentMemberController())->index($request)->getData(true);
  261. $this->assertSame(0, $response['code'], $response['msg']);
  262. $agentRows = collect($response['data']['data'])->keyBy('member_id');
  263. $this->assertEqualsCanonicalizing([1002, 1006], $agentRows->keys()->all());
  264. $this->assertArrayNotHasKey('superior', $agentRows['1006']);
  265. $this->assertArrayNotHasKey('share_parent', $agentRows['1006']);
  266. $this->assertStringNotContainsString('inviter1001', json_encode($agentRows['1006'], JSON_THROW_ON_ERROR));
  267. }
  268. public function test_member_amount_columns_sort_before_pagination(): void
  269. {
  270. DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', max(...), 2);
  271. Schema::create('wallets', function (Blueprint $t) {
  272. $t->id(); $t->string('member_id'); $t->decimal('available_balance');
  273. $t->decimal('wager_required'); $t->decimal('wager_completed');
  274. });
  275. Schema::create('balance_logs', function (Blueprint $t) {
  276. $t->id(); $t->string('member_id'); $t->integer('type'); $t->string('change_type'); $t->decimal('amount');
  277. });
  278. DB::table('users')->insert([
  279. ['user_id' => 'account2', 'member_id' => '1002'],
  280. ['user_id' => 'account3', 'member_id' => '1003'],
  281. ]);
  282. DB::table('wallets')->insert([
  283. ['member_id' => '1001', 'available_balance' => 100, 'wager_required' => 50, 'wager_completed' => 40],
  284. ['member_id' => '1002', 'available_balance' => 2, 'wager_required' => 100, 'wager_completed' => 0],
  285. ]);
  286. $this->app->instance('validator', new Factory($this->app['translator'], $this->app));
  287. Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  288. foreach (['available_balance' => ['1002', '1001'], 'wager_remaining' => ['1001', '1002']] as $field => [$lower, $higher]) {
  289. foreach ([['asc', 2, $lower], ['desc', 1, $higher]] as [$order, $page, $memberId]) {
  290. $filters = ['sort_field' => $field, 'sort_order' => $order, 'page' => $page, 'limit' => 1];
  291. $this->app->instance('request', Request::create('/admin/user', 'GET', $filters));
  292. $body = (new AdminUser())->index()->getData(true);
  293. $this->assertSame(0, $body['code'], $body['msg']);
  294. $this->assertSame(3, $body['data']['total']);
  295. $this->assertSame([$memberId], array_column($body['data']['data'], 'member_id'));
  296. }
  297. }
  298. }
  299. public function test_pending_badges_track_raw_writes_claim_audits_and_aged_orders(): void
  300. {
  301. $this->tick(0);
  302. // 不经过 Eloquent 或事件登记,模拟共享数据库另一应用的写入。
  303. DB::table('payment_orders')->where('type', 3)->update(['status' => 2]);
  304. DB::table('share_claims')->where('id', 1)->update(['status' => 'rejected']);
  305. DB::table('recharges')->update(['created_at' => date('Y-m-d H:i:s', time() - 90000)]);
  306. $events = $this->tick(6);
  307. $this->assertCount(1, $events);
  308. $this->assertSame('admin.stats.unprocessed', $events[0]['channel']);
  309. $this->assertSame(0, $events[0]['data']['rgRecharge']);
  310. $this->assertSame(0, $events[0]['data']['usdtRecharge']);
  311. $this->assertSame(1, $events[0]['data']['shareClaim']);
  312. $this->assertSame((new PaymentOrder())->unProcessed()->getData(true)['data'], $events[0]['data']);
  313. $this->assertSame(8, DB::table('payment_orders')->count(), '扫描只读业务表');
  314. }
  315. public function test_failed_outbox_registration_does_not_swallow_the_snapshot_change(): void
  316. {
  317. Schema::rename('admin_realtime_events', 'unavailable_realtime_events');
  318. AdminRealtimeSummaries::scan();
  319. $this->assertNotEmpty($this->app['log']->records);
  320. Schema::rename('unavailable_realtime_events', 'admin_realtime_events');
  321. $events = $this->tick(6);
  322. $this->assertCount(2, $events, '事件表恢复后仍会登记两个初始快照');
  323. $this->assertSame([], $this->tick(12));
  324. }
  325. }