AdminRealtimeSummaryTest.php 19 KB

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