AdminRealtimeTest.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  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\AdminRealtimeAccess;
  10. use App\Services\JwtService;
  11. use App\Http\Controllers\admin\Realtime as RealtimeController;
  12. use Illuminate\Broadcasting\BroadcastManager;
  13. use Illuminate\Contracts\Broadcasting\Factory as BroadcastingFactory;
  14. use Carbon\Carbon;
  15. use Illuminate\Cache\ArrayStore;
  16. use Illuminate\Cache\Repository as CacheRepository;
  17. use Illuminate\Config\Repository;
  18. use Illuminate\Database\Capsule\Manager;
  19. use Illuminate\Database\DatabaseTransactionsManager;
  20. use Illuminate\Database\Eloquent\Model;
  21. use Illuminate\Database\Schema\Blueprint;
  22. use Illuminate\Events\Dispatcher;
  23. use Illuminate\Foundation\Application;
  24. use Illuminate\Http\JsonResponse;
  25. use Illuminate\Http\Request;
  26. use Illuminate\Support\Facades\DB;
  27. use Illuminate\Support\Facades\Facade;
  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 AdminRealtimeTest extends TestCase
  34. {
  35. private Application $app;
  36. private object $log;
  37. protected function setUp(): void
  38. {
  39. Carbon::setTestNow(Carbon::parse('2026-10-31 10:00:00', 'Asia/Shanghai'));
  40. Facade::clearResolvedInstances();
  41. Model::clearBootedModels();
  42. $this->app = new Application(dirname(__DIR__, 2));
  43. $this->app->instance('config', new Repository([
  44. 'app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'],
  45. 'admin_realtime' => ['enabled' => true, 'connection' => 'reverb', 'batch_size' => 200, 'gap_seconds' => 60, 'replay_limit' => 1000,
  46. 'permission_cache_seconds' => 60, 'online_scan_seconds' => 15, 'retention_hours' => 72],
  47. ]));
  48. Facade::setFacadeApplication($this->app);
  49. $events = new Dispatcher($this->app); $this->app->instance('events', $events);
  50. $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  51. $db->setEventDispatcher($events); $db->setAsGlobal(); $db->bootEloquent();
  52. $db->getConnection()->setTransactionManager(new DatabaseTransactionsManager());
  53. $this->app->instance('db', $db->getDatabaseManager());
  54. $this->app->bind('db.schema', fn () => $db->schema());
  55. $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator);
  56. $this->app->instance('validator', new Factory($translator, $this->app));
  57. $this->log = new class {
  58. public array $records = [];
  59. public function channel($name = null) { return $this; }
  60. public function __call($level, $args) { $this->records[] = [$level, $args[0] ?? '']; }
  61. };
  62. $this->app->instance('log', $this->log);
  63. $this->app->instance('cache', new CacheRepository(new ArrayStore()));
  64. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  65. Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  66. $this->app->instance('request', Request::create('/admin/realtime/events', 'GET'));
  67. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_31_160000_create_admin_realtime_events.php')->up();
  68. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_31_210000_add_seq_to_admin_realtime_events.php')->up();
  69. Schema::create('users', function (Blueprint $t) {
  70. $t->id(); $t->string('member_id'); $t->string('user_id')->default(''); $t->string('first_name')->default('');
  71. $t->string('admin_note')->nullable(); $t->string('language')->nullable(); $t->integer('recharge_channel_group_id')->nullable(); $t->timestamps();
  72. });
  73. Schema::create('withdraws', function (Blueprint $t) {
  74. $t->id(); $t->string('member_id'); $t->decimal('amount', 20, 10)->default(0); $t->decimal('service_charge', 20, 10)->default(0);
  75. $t->decimal('to_account', 20, 10)->default(0); $t->string('address')->default(''); $t->decimal('exchange_rate', 20, 10)->default(0);
  76. $t->integer('status')->default(0); $t->decimal('after_balance', 20, 10)->default(0); $t->string('remark')->nullable();
  77. $t->string('admin_note')->nullable(); $t->integer('is_locked')->default(0); $t->integer('is_send')->default(0); $t->timestamps();
  78. });
  79. DB::table('users')->insert(['member_id' => '9001', 'user_id' => 'u9001', 'first_name' => 'Alice', 'admin_note' => 'vip']);
  80. }
  81. protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
  82. private function events(): array
  83. {
  84. return DB::table('admin_realtime_events')->orderBy('id')->get()->map(fn ($r) => "{$r->resource}:{$r->resource_id}:{$r->action}")->all();
  85. }
  86. public function test_record_waits_for_commit_and_drops_on_rollback(): void
  87. {
  88. AdminRealtime::record('withdraws', 1);
  89. $this->assertSame(['withdraws:1:updated'], $this->events());
  90. DB::beginTransaction();
  91. AdminRealtime::record('withdraws', [2, 2, 3], 'created');
  92. $this->assertCount(1, $this->events(), '事务内不写入');
  93. DB::commit();
  94. $this->assertSame(['withdraws:1:updated', 'withdraws:2:created', 'withdraws:3:created'], $this->events());
  95. DB::beginTransaction();
  96. AdminRealtime::record('withdraws', 4);
  97. DB::rollBack();
  98. $this->assertCount(3, $this->events(), '回滚后丢弃');
  99. $this->app['config']->set('admin_realtime.enabled', false);
  100. AdminRealtime::record('withdraws', 5);
  101. $this->assertCount(3, $this->events(), '关闭时不登记');
  102. }
  103. public function test_record_failure_never_breaks_business_transaction(): void
  104. {
  105. Schema::drop('admin_realtime_events');
  106. DB::transaction(function () {
  107. Withdraw::create(['member_id' => '9001', 'amount' => 10]);
  108. });
  109. $this->assertSame(1, Withdraw::count(), '事件表缺失也不影响主业务');
  110. $this->assertSame('error', $this->log->records[0][0] ?? null);
  111. }
  112. public function test_model_hooks_capture_orders_and_new_users_only(): void
  113. {
  114. $withdraw = Withdraw::create(['member_id' => '9001', 'amount' => 10]);
  115. $withdraw->update(['is_send' => 1]);
  116. $withdraw->update(['status' => 1]);
  117. $user = User::query()->create(['member_id' => '9002', 'first_name' => 'Bob']);
  118. $user->first_name = 'Bobby'; $user->save();
  119. $withdraw->delete();
  120. $this->assertSame([
  121. "withdraws:{$withdraw->id}:created", "withdraws:{$withdraw->id}:updated",
  122. "users:{$user->id}:created", "withdraws:{$withdraw->id}:deleted",
  123. ], $this->events());
  124. }
  125. public function test_feed_resolves_rows_with_the_list_endpoint_shape(): void
  126. {
  127. $this->assertSame(0, AdminRealtimeFeed::startCursor());
  128. $this->assertSame(['cursor' => 0, 'events' => [], 'has_more' => false], AdminRealtimeFeed::next(0));
  129. $withdraw = Withdraw::create(['member_id' => '9001', 'amount' => 12.5, 'to_account' => 12.5]);
  130. $withdraw->update(['status' => 1]);
  131. Withdraw::create(['member_id' => '9001', 'amount' => 3]);
  132. $result = AdminRealtimeFeed::next(0);
  133. $this->assertSame(3, $result['cursor']);
  134. $this->assertSame([1, 2, 3], DB::table('admin_realtime_events')->orderBy('id')->pluck('seq')->map(fn ($v) => (int) $v)->all());
  135. $this->assertCount(2, $result['events'], '同一记录一批内合并');
  136. $first = $result['events'][0];
  137. $this->assertSame(['seq' => 2, 'id' => 2, 'channel' => 'admin.orders.withdraw', 'event' => 'order.created', 'action' => 'created', 'key' => ['id' => 1]],
  138. array_intersect_key($first, array_flip(['seq', 'id', 'channel', 'event', 'action', 'key'])));
  139. $listRequest = Request::create('/admin/withdraw', 'GET');
  140. $this->app->instance('request', $listRequest);
  141. $list = (new WithdrawController())->index()->getData(true)['data']['data'];
  142. $row = collect($list)->firstWhere('id', 1);
  143. $this->assertSame($row, $first['data'], '推送行与列表接口返回的行一致');
  144. $this->assertSame('Alice', $first['data']['first_name']);
  145. $this->assertSame(1, $first['data']['status']);
  146. $withdraw->delete();
  147. $deleted = AdminRealtimeFeed::next(3)['events'];
  148. $this->assertSame([['seq' => 4, 'channel' => 'admin.orders.withdraw', 'action' => 'deleted', 'key' => ['id' => 1], 'data' => null]],
  149. array_map(fn ($e) => array_intersect_key($e, array_flip(['seq', 'channel', 'action', 'key', 'data'])), $deleted));
  150. $this->assertSame(4, AdminRealtimeFeed::startCursor(), '重启后从上次广播到的 id 继续');
  151. $this->assertNotEmpty(array_filter($this->log->records, fn ($r) => $r[1] === 'admin_realtime_online_scan_failed'),
  152. 'SQLite 不支持在线判定 SQL,扫描失败只记日志不影响广播');
  153. }
  154. public function test_feed_delivers_late_commits_with_a_later_seq(): void
  155. {
  156. $insert = fn (int $id, string $at) => DB::table('admin_realtime_events')->insert([
  157. 'id' => $id, 'resource' => 'withdraws', 'resource_id' => (string) $id, 'action' => 'deleted', 'source' => 'melbet', 'created_at' => $at,
  158. ]);
  159. $insert(1, '2026-10-31 09:59:59');
  160. $insert(3, '2026-10-31 09:59:59');
  161. $this->assertSame([1, 3], array_column(AdminRealtimeFeed::next(0)['events'], 'id'));
  162. $insert(2, '2026-10-31 09:59:30');
  163. $insert(4, '2026-10-31 09:58:00');
  164. $insert(5, '2026-10-31 09:58:00');
  165. $batch = AdminRealtimeFeed::next(3);
  166. $this->assertSame([[3, 2], [4, 4], [5, 5]], array_map(fn ($e) => [$e['seq'], $e['id']], $batch['events']),
  167. '游标之前晚提交的 id 仍会广播,seq 按广播先后递增');
  168. $this->assertSame(5, $batch['cursor']);
  169. $insert(6, '2026-10-31 10:00:00');
  170. $insert(7, '2026-10-31 10:00:00');
  171. DB::table('admin_realtime_events')->where('id', 6)->delete();
  172. AdminRealtimeFeed::next(5);
  173. $insert(6, '2026-10-31 09:58:00');
  174. $this->assertSame([], AdminRealtimeFeed::next(7)['events'], '超过 gap_seconds 的晚提交不再补发');
  175. }
  176. public function test_replay_by_seq_filters_channels_and_requests_resync(): void
  177. {
  178. $withdraw = Withdraw::create(['member_id' => '9001', 'amount' => 1]);
  179. Withdraw::create(['member_id' => '9001', 'amount' => 2]);
  180. User::query()->create(['member_id' => '9003', 'first_name' => 'Carol']);
  181. $withdraw->delete();
  182. AdminRealtimeFeed::next(0);
  183. $this->assertSame(4, AdminRealtimeFeed::lastSeq());
  184. $replay = AdminRealtimeFeed::replay(1, ['admin.orders.withdraw']);
  185. $this->assertFalse($replay['resync']);
  186. $this->assertSame(4, $replay['last_seq']);
  187. $this->assertSame([[2, 'order.created'], [4, 'order.deleted']], array_map(fn ($e) => [$e['seq'], $e['event']], $replay['events']),
  188. '只返回请求的频道,并按当前数据解析');
  189. $this->assertSame([], AdminRealtimeFeed::replay(4, ['admin.orders.withdraw'])['events']);
  190. $this->assertSame('invalid_cursor', AdminRealtimeFeed::replay(9, ['admin.orders.withdraw'])['reason']);
  191. $this->app['config']->set('admin_realtime.replay_limit', 2);
  192. $this->assertSame('too_many_events', AdminRealtimeFeed::replay(0, ['admin.orders.withdraw'])['reason']);
  193. DB::table('admin_realtime_events')->where('seq', 1)->delete();
  194. $this->assertSame('history_unavailable', AdminRealtimeFeed::replay(1, ['admin.orders.withdraw'])['reason'], '游标已被清理时重新拉列表');
  195. }
  196. public function test_channel_auth_signs_session_user_for_reverb(): void
  197. {
  198. $this->bindBroadcasting();
  199. Schema::create('admin', function (Blueprint $t) { $t->id(); $t->string('username'); $t->timestamps(); });
  200. DB::table('admin')->insert(['id' => 1, 'username' => 'root']);
  201. $exp = time() + 3600;
  202. $token = \Firebase\JWT\JWT::encode(['iat' => time(), 'exp' => $exp, 'sub' => 1, 'user_id' => 1], str_repeat('k', 32), 'HS256');
  203. $call = function (?string $token, string $channel) {
  204. $request = Request::create('/admin/broadcasting/auth', 'POST', ['socket_id' => '123.456', 'channel_name' => $channel]);
  205. if ($token) $request->headers->set('Authorization', 'Bearer ' . $token);
  206. $this->app->instance('request', $request);
  207. return (new RealtimeController())->auth(new JwtService());
  208. };
  209. $response = $call($token, 'private-admin.orders.withdraw');
  210. $this->assertSame(200, $response->getStatusCode());
  211. $body = $response->getData(true);
  212. $this->assertSame(['user_id' => "1:{$exp}"], json_decode($body['channel_data'], true));
  213. // 与 Reverb 校验私有频道的签名算法一致(InteractsWithPrivateChannels::verify)
  214. $this->assertSame('app-key:' . hash_hmac('sha256', "123.456:private-admin.orders.withdraw:{$body['channel_data']}", 'app-secret'), $body['auth']);
  215. $this->assertSame(401, $call(null, 'private-admin.orders.withdraw')->getStatusCode());
  216. $this->assertSame(401, $call($token . 'x', 'private-admin.orders.withdraw')->getStatusCode());
  217. $this->assertSame(403, $call($token, 'private-admin.orders.unknown')->getStatusCode());
  218. $this->assertSame(403, $call($token, 'admin.orders.withdraw')->getStatusCode(), '只授权私有频道');
  219. $this->assertSame(array_keys(AdminRealtimeRows::CHANNELS), AdminRealtimeAccess::allowedChannels(1));
  220. }
  221. public function test_expired_sessions_are_terminated_once(): void
  222. {
  223. $this->bindBroadcasting();
  224. AdminRealtimeAccess::authorize(7, time() - 1, 'private-admin.orders.withdraw', '1.1');
  225. AdminRealtimeAccess::authorize(7, time() - 1, 'private-admin.users', '1.1');
  226. AdminRealtimeAccess::authorize(8, time() + 600, 'private-admin.users', '2.2');
  227. $this->assertSame(['7:' . (time() - 1)], AdminRealtimeAccess::terminateExpiredSessions());
  228. $this->assertSame([], AdminRealtimeAccess::terminateExpiredSessions(), '已断开的会话不重复处理');
  229. $this->assertNotEmpty(array_filter($this->log->records, fn ($r) => $r[1] === 'admin_realtime_terminate_failed'),
  230. 'Reverb 不可达时只记日志');
  231. }
  232. private function bindBroadcasting(): void
  233. {
  234. $this->app['config']->set('broadcasting.connections.reverb', [
  235. 'driver' => 'reverb', 'key' => 'app-key', 'secret' => 'app-secret', 'app_id' => 'app-id',
  236. 'options' => ['host' => '127.0.0.1', 'port' => 1, 'scheme' => 'http', 'useTLS' => false],
  237. 'client_options' => ['connect_timeout' => 1, 'timeout' => 1],
  238. ]);
  239. $this->app['config']->set('app.jwt_secret', str_repeat('k', 32));
  240. $this->app['config']->set('app.jwt_exp', 3600);
  241. $this->app->instance(BroadcastingFactory::class, new BroadcastManager($this->app));
  242. }
  243. }