AdminRealtimeTest.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\Withdraw as WithdrawController;
  4. use App\Models\User;
  5. use App\Models\Withdraw;
  6. use App\Services\AdminRealtime\AdminRealtime;
  7. use App\Services\AdminRealtime\AdminRealtimeFeed;
  8. use App\Services\AdminRealtime\AdminRealtimeRows;
  9. use App\Services\AdminRealtime\AdminRealtimeTicket;
  10. use Carbon\Carbon;
  11. use Illuminate\Cache\ArrayStore;
  12. use Illuminate\Cache\Repository as CacheRepository;
  13. use Illuminate\Config\Repository;
  14. use Illuminate\Database\Capsule\Manager;
  15. use Illuminate\Database\DatabaseTransactionsManager;
  16. use Illuminate\Database\Eloquent\Model;
  17. use Illuminate\Database\Schema\Blueprint;
  18. use Illuminate\Events\Dispatcher;
  19. use Illuminate\Foundation\Application;
  20. use Illuminate\Http\JsonResponse;
  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 AdminRealtimeTest extends TestCase
  30. {
  31. private Application $app;
  32. private object $log;
  33. protected function setUp(): void
  34. {
  35. Carbon::setTestNow(Carbon::parse('2026-10-31 10:00:00', 'Asia/Shanghai'));
  36. Facade::clearResolvedInstances();
  37. Model::clearBootedModels();
  38. $this->app = new Application(dirname(__DIR__, 2));
  39. $this->app->instance('config', new Repository([
  40. 'app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'],
  41. 'admin_realtime' => ['enabled' => true, 'ticket_secret' => 'ticket-secret', 'ticket_ttl' => 60, 'public_url' => 'wss://admin.test/admin-ws', 'batch_size' => 200, 'online_scan_seconds' => 15, 'retention_hours' => 72],
  42. ]));
  43. Facade::setFacadeApplication($this->app);
  44. $events = new Dispatcher($this->app); $this->app->instance('events', $events);
  45. $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  46. $db->setEventDispatcher($events); $db->setAsGlobal(); $db->bootEloquent();
  47. $db->getConnection()->setTransactionManager(new DatabaseTransactionsManager());
  48. $this->app->instance('db', $db->getDatabaseManager());
  49. $this->app->bind('db.schema', fn () => $db->schema());
  50. $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator);
  51. $this->app->instance('validator', new Factory($translator, $this->app));
  52. $this->log = new class {
  53. public array $records = [];
  54. public function channel($name = null) { return $this; }
  55. public function __call($level, $args) { $this->records[] = [$level, $args[0] ?? '']; }
  56. };
  57. $this->app->instance('log', $this->log);
  58. $this->app->instance('cache', new CacheRepository(new ArrayStore()));
  59. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
  60. public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); }
  61. });
  62. Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  63. $this->app->instance('request', Request::create('/admin/realtime/events', 'GET'));
  64. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_31_160000_create_admin_realtime_events.php')->up();
  65. Schema::create('users', function (Blueprint $t) {
  66. $t->id(); $t->string('member_id'); $t->string('user_id')->default(''); $t->string('first_name')->default('');
  67. $t->string('admin_note')->nullable(); $t->string('language')->nullable(); $t->integer('recharge_channel_group_id')->nullable(); $t->timestamps();
  68. });
  69. Schema::create('withdraws', function (Blueprint $t) {
  70. $t->id(); $t->string('member_id'); $t->decimal('amount', 20, 10)->default(0); $t->decimal('service_charge', 20, 10)->default(0);
  71. $t->decimal('to_account', 20, 10)->default(0); $t->string('address')->default(''); $t->decimal('exchange_rate', 20, 10)->default(0);
  72. $t->integer('status')->default(0); $t->decimal('after_balance', 20, 10)->default(0); $t->string('remark')->nullable();
  73. $t->string('admin_note')->nullable(); $t->integer('is_locked')->default(0); $t->integer('is_send')->default(0); $t->timestamps();
  74. });
  75. DB::table('users')->insert(['member_id' => '9001', 'user_id' => 'u9001', 'first_name' => 'Alice', 'admin_note' => 'vip']);
  76. }
  77. protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
  78. private function events(): array
  79. {
  80. return DB::table('admin_realtime_events')->orderBy('id')->get()->map(fn ($r) => "{$r->resource}:{$r->resource_id}:{$r->action}")->all();
  81. }
  82. public function test_record_waits_for_commit_and_drops_on_rollback(): void
  83. {
  84. AdminRealtime::record('withdraws', 1);
  85. $this->assertSame(['withdraws:1:updated'], $this->events());
  86. DB::beginTransaction();
  87. AdminRealtime::record('withdraws', [2, 2, 3], 'created');
  88. $this->assertCount(1, $this->events(), '事务内不写入');
  89. DB::commit();
  90. $this->assertSame(['withdraws:1:updated', 'withdraws:2:created', 'withdraws:3:created'], $this->events());
  91. DB::beginTransaction();
  92. AdminRealtime::record('withdraws', 4);
  93. DB::rollBack();
  94. $this->assertCount(3, $this->events(), '回滚后丢弃');
  95. $this->app['config']->set('admin_realtime.enabled', false);
  96. AdminRealtime::record('withdraws', 5);
  97. $this->assertCount(3, $this->events(), '关闭时不登记');
  98. }
  99. public function test_record_failure_never_breaks_business_transaction(): void
  100. {
  101. Schema::drop('admin_realtime_events');
  102. DB::transaction(function () {
  103. Withdraw::create(['member_id' => '9001', 'amount' => 10]);
  104. });
  105. $this->assertSame(1, Withdraw::count(), '事件表缺失也不影响主业务');
  106. $this->assertSame('error', $this->log->records[0][0] ?? null);
  107. }
  108. public function test_model_hooks_capture_orders_and_new_users_only(): void
  109. {
  110. $withdraw = Withdraw::create(['member_id' => '9001', 'amount' => 10]);
  111. $withdraw->update(['is_send' => 1]);
  112. $withdraw->update(['status' => 1]);
  113. $user = User::query()->create(['member_id' => '9002', 'first_name' => 'Bob']);
  114. $user->first_name = 'Bobby'; $user->save();
  115. $withdraw->delete();
  116. $this->assertSame([
  117. "withdraws:{$withdraw->id}:created", "withdraws:{$withdraw->id}:updated",
  118. "users:{$user->id}:created", "withdraws:{$withdraw->id}:deleted",
  119. ], $this->events());
  120. }
  121. public function test_feed_resolves_rows_with_the_list_endpoint_shape(): void
  122. {
  123. $this->assertSame(['last_id' => 0, 'ids' => [], 'events' => [], 'has_more' => false], AdminRealtimeFeed::pull(-1));
  124. $withdraw = Withdraw::create(['member_id' => '9001', 'amount' => 12.5, 'to_account' => 12.5]);
  125. $withdraw->update(['status' => 1]);
  126. Withdraw::create(['member_id' => '9001', 'amount' => 3]);
  127. $result = AdminRealtimeFeed::pull(0);
  128. $this->assertSame(3, $result['last_id']);
  129. $this->assertSame([1, 2, 3], $result['ids']);
  130. $this->assertCount(2, $result['events'], '同一记录一批内合并');
  131. $first = $result['events'][0];
  132. $this->assertSame(['id' => 2, 'channel' => 'admin.orders.withdraw', 'event' => 'order.created', 'action' => 'created', 'key' => ['id' => 1]],
  133. array_intersect_key($first, array_flip(['id', 'channel', 'event', 'action', 'key'])));
  134. $listRequest = Request::create('/admin/withdraw', 'GET');
  135. $this->app->instance('request', $listRequest);
  136. $list = (new WithdrawController())->index()->getData(true)['data']['data'];
  137. $row = collect($list)->firstWhere('id', 1);
  138. $this->assertSame($row, $first['data'], '推送行与列表接口返回的行一致');
  139. $this->assertSame('Alice', $first['data']['first_name']);
  140. $this->assertSame(1, $first['data']['status']);
  141. $withdraw->delete();
  142. $deleted = AdminRealtimeFeed::pull(3)['events'];
  143. $this->assertSame([['channel' => 'admin.orders.withdraw', 'action' => 'deleted', 'key' => ['id' => 1], 'data' => null]],
  144. array_map(fn ($e) => array_intersect_key($e, array_flip(['channel', 'action', 'key', 'data'])), $deleted));
  145. $gapped = AdminRealtimeFeed::pull(4, ['2']);
  146. $this->assertSame([2], $gapped['ids'], '按 gaps 补拉已处理游标之前的事件');
  147. $this->assertSame(4, $gapped['last_id']);
  148. $this->assertNotEmpty(array_filter($this->log->records, fn ($r) => $r[1] === 'admin_realtime_online_scan_failed'),
  149. 'SQLite 不支持在线判定 SQL,扫描失败只记日志不影响拉取');
  150. }
  151. public function test_ticket_is_signed_for_gateway_and_lists_channels(): void
  152. {
  153. $ticket = AdminRealtimeTicket::issue(1, 1893456000);
  154. [$body, $sig] = explode('.', $ticket['ticket']);
  155. $this->assertSame(rtrim(strtr(base64_encode(hash_hmac('sha256', $body, 'ticket-secret', true)), '+/', '-_'), '='), $sig);
  156. $payload = json_decode(base64_decode(strtr($body, '-_', '+/')), true);
  157. $this->assertSame(1, $payload['aid']);
  158. $this->assertSame(1893456000, $payload['sxp']);
  159. $this->assertSame(array_keys(AdminRealtimeRows::CHANNELS), $payload['ch']);
  160. $this->assertSame('wss://admin.test/admin-ws', $ticket['url']);
  161. $node = trim((string) shell_exec('command -v node'));
  162. if ($node === '') $this->markTestIncomplete('node 不可用,跳过网关签名互通校验');
  163. $script = "const c=require('crypto');const [b,s]=process.argv[1].split('.');process.stdout.write(String(c.createHmac('sha256','ticket-secret').update(b).digest('base64url')===s))";
  164. $this->assertSame('true', shell_exec(escapeshellarg($node) . ' -e ' . escapeshellarg($script) . ' ' . escapeshellarg($ticket['ticket'])));
  165. }
  166. }