ThirdGamePlayerIdentityTest.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Services\ThirdGameBalanceService;
  4. use App\Services\ThirdGameCommissionService;
  5. use App\Services\ThirdGameOrderService;
  6. use Carbon\Carbon;
  7. use Illuminate\Cache\ArrayStore;
  8. use Illuminate\Cache\Repository as CacheRepository;
  9. use Illuminate\Config\Repository;
  10. use Illuminate\Database\Capsule\Manager;
  11. use Illuminate\Foundation\Application;
  12. use Illuminate\Http\Client\Factory;
  13. use Illuminate\Support\Facades\DB;
  14. use Illuminate\Support\Facades\Facade;
  15. use Illuminate\Support\Facades\Http;
  16. use PHPUnit\Framework\TestCase;
  17. /** Provider order identities are resolved against an isolated SQLite database only. */
  18. class ThirdGamePlayerIdentityTest extends TestCase
  19. {
  20. protected function setUp(): void
  21. {
  22. Facade::clearResolvedInstances();
  23. $app = new Application(dirname(__DIR__, 2));
  24. $app->instance('config', new Repository(['app' => ['timezone' => 'Asia/Shanghai'], 'third_game' => ['player_prefix' => 'u']]));
  25. Facade::setFacadeApplication($app);
  26. $db = new Manager($app);
  27. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  28. $db->setAsGlobal(); $db->bootEloquent(); $app->instance('db', $db->getDatabaseManager());
  29. $app->bind('db.schema', fn () => $db->schema());
  30. DB::connection()->getPdo()->sqliteCreateFunction('MD5', md5(...), 1);
  31. DB::statement('CREATE TABLE bot_users (id INTEGER PRIMARY KEY,member_id TEXT,username TEXT,account TEXT,first_name TEXT)');
  32. (require dirname(__DIR__, 2) . '/database/migrations/2026_08_21_120000_create_third_game_orders_table.php')->up();
  33. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_140000_create_reporting_tables.php')->up();
  34. $this->commissionMigration()->up();
  35. DB::table('users')->insert([
  36. ['id' => 1, 'member_id' => 'm1', 'username' => 'one', 'first_name' => ''],
  37. ['id' => 106, 'member_id' => 'm106', 'username' => 'member106', 'first_name' => ''],
  38. ['id' => 1000, 'member_id' => 'm1000', 'username' => 'member1000', 'first_name' => ''],
  39. ]);
  40. }
  41. protected function tearDown(): void
  42. {
  43. Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
  44. }
  45. private function lookup(array $playerIds): array
  46. {
  47. $service = new ThirdGameOrderService(new ThirdGameBalanceService());
  48. return (new \ReflectionMethod($service, 'usersByPlayerId'))->invoke($service, $playerIds);
  49. }
  50. public function test_new_padded_and_legacy_identifiers_in_one_batch_resolve_to_the_correct_member(): void
  51. {
  52. $rows = $this->lookup(['u106', 'u0106', 'u0001', 'u1', 'u1000']);
  53. $this->assertCount(5, $rows);
  54. $this->assertSame(106, $rows['u0106']->id);
  55. $this->assertSame(106, $rows['u106']->id);
  56. $this->assertSame('m106', $rows['u0106']->member_id);
  57. $this->assertSame(1, $rows['u0001']->id);
  58. $this->assertSame(1, $rows['u1']->id);
  59. $this->assertSame(1000, $rows['u1000']->id);
  60. }
  61. public function test_noncanonical_zero_padding_and_unknown_accounts_do_not_get_bound_to_another_member(): void
  62. {
  63. $this->assertSame([], $this->lookup(['u000106', 'u01', 'u00001', 'u01000', 'u0000',
  64. 'u0', 'u10000000000', 'u0999', 'a0106', 'U0106', 'p1234567890']));
  65. $this->assertSame([], $this->lookup([]));
  66. }
  67. public function test_test_namespace_does_not_associate_production_players_with_test_members(): void
  68. {
  69. config(['third_game.player_prefix' => 'tu']);
  70. $rows = $this->lookup(['u106', 'u0106', 'u1000', 'tu106', 'tu0106', 'tu1000']);
  71. $this->assertSame(['tu106', 'tu0106', 'tu1000'], array_keys($rows));
  72. $this->assertSame(106, $rows['tu0106']->id);
  73. $this->assertSame([], $this->lookup(['u0106', 'tu00106', 'tu0000', 'tu1000000000']));
  74. }
  75. public function test_sync_and_listing_keep_namespaces_separate_even_when_an_old_foreign_snapshot_exists(): void
  76. {
  77. config(['third_game.player_prefix' => 'tu']);
  78. $foreign = ['player_id' => 'u0106', 'platform' => 'pg', 'game_order_id' => 'production-order',
  79. 'currency' => 'CNY', 'game_type' => 2, 'status' => 1, 'bet_amount' => '999',
  80. 'bet_time' => '2026-10-27 10:00:00', 'last_update_time' => '2026-10-27 10:01:00'];
  81. $foreignKey = hash('sha256', "pg\0u0106\0production-order");
  82. DB::table('third_game_orders')->insert($foreign + ['order_key' => $foreignKey, 'user_id' => 106,
  83. 'member_id' => 'm106', 'username' => 'member106', 'first_name' => '', 'created_at' => '2026-10-27 10:00:00']);
  84. $before = (array) DB::table('third_game_orders')->where('order_key', $foreignKey)->first();
  85. $local = array_replace($foreign, ['player_id' => 'tu0106', 'game_order_id' => 'test-order', 'bet_amount' => '10']);
  86. $service = new ThirdGameOrderService(new ThirdGameBalanceService());
  87. $persist = new \ReflectionMethod($service, 'persistProviderResult');
  88. $result = $persist->invoke($service, ['ok' => true, 'list' => [$foreign, $local],
  89. 'page_no' => 1, 'page_size' => 2, 'total' => 4]);
  90. $this->assertSame(2, $result['received']);
  91. $this->assertSame(1, $result['synced']);
  92. $this->assertSame(1, $result['matched_users']);
  93. $this->assertSame(4, $result['total']);
  94. $this->assertTrue($result['has_more']);
  95. $this->assertSame($before, (array) DB::table('third_game_orders')->where('order_key', $foreignKey)->first());
  96. $this->assertSame(106, DB::table('third_game_orders')->where('player_id', 'tu0106')->value('user_id'));
  97. $list = $service->paginate([]);
  98. $this->assertSame(1, $list['total']);
  99. $this->assertSame('tu0106', $list['list'][0]['player_id']);
  100. config(['third_game.player_prefix' => 'u']);
  101. $production = $service->paginate([]);
  102. $this->assertSame(1, $production['total']);
  103. $this->assertSame('u0106', $production['list'][0]['player_id']);
  104. $rejected = $persist->invoke($service, ['ok' => true, 'list' => [$local], 'page_no' => 1, 'page_size' => 1, 'total' => 1]);
  105. $this->assertSame(0, $rejected['synced']);
  106. $this->assertSame(2, DB::table('third_game_orders')->count());
  107. }
  108. public function test_order_sync_snapshots_only_losses_and_keeps_the_rate_during_corrections(): void
  109. {
  110. Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai'));
  111. $this->fakeQuota([
  112. [['platfrom' => 'ag', 'ratio' => '0.09'], ['platfrom' => 'free', 'ratio' => '0']],
  113. [['platfrom' => 'ag', 'ratio' => '0.20']],
  114. ]);
  115. $provider = new class extends ThirdGameBalanceService {
  116. public array $orders;
  117. public function realtimeOrders(int $page = 1, int $pageSize = 2000): array
  118. {
  119. return ['ok' => true, 'list' => $this->orders];
  120. }
  121. public function historyOrders(string $startTime, string $endTime, int $page = 1, int $pageSize = 2000): array
  122. {
  123. return ['ok' => true, 'list' => $this->orders];
  124. }
  125. };
  126. $service = new ThirdGameOrderService($provider);
  127. $loss = $this->order('loss');
  128. $provider->orders = [
  129. $loss,
  130. $this->order('win', ['settled_amount' => '20']),
  131. $this->order('draw', ['settled_amount' => '0']),
  132. $this->order('pending', ['status' => 0]),
  133. $this->order('missing-net', ['settled_amount' => null]),
  134. $this->order('missing-rate', ['platform' => 'pg']),
  135. $this->order('zero-rate', ['platform' => 'free']),
  136. $this->order('foreign', ['currency' => 'USD']),
  137. ];
  138. $this->assertSame(8, $service->syncRealtime()['synced']);
  139. $byOrder = fn () => collect($service->paginate([])['list'])->keyBy('game_order_id');
  140. $rows = $byOrder();
  141. $this->assertSame('loss', $rows['loss']['bet_result']);
  142. $this->assertSame('0.0900000000', $rows['loss']['third_game_commission_ratio']);
  143. $this->assertSame('9.0000000000', $rows['loss']['third_game_commission']);
  144. $this->assertSame('win', $rows['win']['bet_result']);
  145. $this->assertSame('draw', $rows['draw']['bet_result']);
  146. $this->assertNull($rows['pending']['bet_result']);
  147. foreach (['win', 'draw', 'pending', 'zero-rate'] as $order) {
  148. $this->assertSame('0.0000000000', $rows[$order]['third_game_commission']);
  149. }
  150. foreach (['missing-net', 'missing-rate', 'foreign'] as $order) {
  151. $this->assertNull($rows[$order]['third_game_commission']);
  152. }
  153. $this->assertSame('0.0000000000', $rows['zero-rate']['third_game_commission_ratio']);
  154. $service->syncRealtime();
  155. Http::assertSentCount(1);
  156. Carbon::setTestNow(Carbon::parse('2026-11-06 12:00:00', 'Asia/Shanghai'));
  157. $correction = ['last_update_time' => '2026-11-06 10:00:00', 'settled_amount' => '-50'] + $loss;
  158. $provider->orders = [$correction, $this->order('new', ['last_update_time' => '2026-11-06 10:00:00'])];
  159. $service->syncHistory('2026-11-06 09:00:00', '2026-11-06 11:00:00');
  160. $this->assertSame('4.5000000000', $byOrder()['loss']['third_game_commission']);
  161. $this->assertSame('20.0000000000', $byOrder()['new']['third_game_commission']);
  162. $provider->orders = [$loss];
  163. $this->assertSame(0, $service->syncRealtime()['synced']);
  164. $this->assertSame('4.5000000000', $byOrder()['loss']['third_game_commission']);
  165. $provider->orders = [['status' => 3, 'last_update_time' => '2026-11-06 11:00:00'] + $loss];
  166. $service->syncRealtime();
  167. $this->assertSame('0.0000000000', $byOrder()['loss']['third_game_commission']);
  168. $this->assertNull($byOrder()['loss']['bet_result']);
  169. $this->assertNull($byOrder()['loss']['third_game_commission_ratio']);
  170. Http::assertSentCount(2);
  171. }
  172. public function test_backfill_freezes_the_launch_range_and_daily_retry_only_fills_new_orders(): void
  173. {
  174. Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai'));
  175. $migration = $this->commissionMigration();
  176. $migration->down();
  177. $this->insertOrder('old-ag');
  178. $this->insertOrder('old-pg', ['platform' => 'pg']);
  179. $this->insertOrder('old-win', ['settled_amount' => '10']);
  180. $migration->up();
  181. $this->insertOrder('new-pg', ['platform' => 'pg', 'last_update_time' => '2026-11-05 13:00:00']);
  182. $this->fakeQuota([
  183. [['platfrom' => 'ag', 'ratio' => '0.09']],
  184. [['platfrom' => 'ag', 'ratio' => '0.20'], ['platfrom' => 'pg', 'ratio' => '0.30']],
  185. ]);
  186. $service = new ThirdGameCommissionService();
  187. $this->assertSame(['updated' => 1, 'remaining' => 1], $service->backfill(false));
  188. Carbon::setTestNow(Carbon::parse('2026-11-06 12:00:00', 'Asia/Shanghai'));
  189. $this->assertSame(['updated' => 0, 'remaining' => 1], $service->backfill(false));
  190. Http::assertSentCount(1);
  191. $this->assertSame(['updated' => 1, 'remaining' => 0], $service->backfill(true));
  192. $rows = collect((new ThirdGameOrderService(new ThirdGameBalanceService()))->paginate([])['list'])->keyBy('game_order_id');
  193. $this->assertSame('9.0000000000', $rows['old-ag']['third_game_commission']);
  194. $this->assertNull($rows['old-pg']['third_game_commission']);
  195. $this->assertSame('0.0000000000', $rows['old-win']['third_game_commission']);
  196. $this->assertSame('30.0000000000', $rows['new-pg']['third_game_commission']);
  197. Http::assertSentCount(2);
  198. }
  199. public function test_failed_rate_lookup_is_retried_the_same_day_and_rows_without_update_time_still_update(): void
  200. {
  201. Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai'));
  202. $this->fakeQuota([[['platfrom' => 'ag', 'ratio' => '0.10']]], [['code' => 500, 'msg' => 'busy']]);
  203. $provider = new class extends ThirdGameBalanceService {
  204. public array $orders;
  205. public function realtimeOrders(int $page = 1, int $pageSize = 2000): array
  206. {
  207. return ['ok' => true, 'list' => $this->orders];
  208. }
  209. };
  210. $service = new ThirdGameOrderService($provider);
  211. $byOrder = fn () => collect($service->paginate([])['list'])->keyBy('game_order_id');
  212. $provider->orders = [$this->order('loss'), $this->order('later', ['status' => 0, 'settled_amount' => null])];
  213. $service->syncRealtime();
  214. $this->assertNull($byOrder()['loss']['third_game_commission'], 'rate lookup failed');
  215. Carbon::setTestNow(Carbon::parse('2026-11-05 12:20:00', 'Asia/Shanghai'));
  216. $service->syncRealtime();
  217. Http::assertSentCount(1);
  218. Carbon::setTestNow(Carbon::parse('2026-11-05 12:31:00', 'Asia/Shanghai'));
  219. $provider->orders = [$this->order('loss'), $this->order('later', ['last_update_time' => null])];
  220. $service->syncRealtime();
  221. Http::assertSentCount(2);
  222. $this->assertSame('10.0000000000', $byOrder()['loss']['third_game_commission'], 'retried after the failure pause');
  223. $this->assertSame(1, $byOrder()['later']['status'], 'a row without update time is not treated as stale');
  224. $this->assertSame('10.0000000000', $byOrder()['later']['third_game_commission']);
  225. // An untimed version cannot be ordered, so it never replaces a finished one.
  226. $provider->orders = [$this->order('loss', ['status' => 0, 'settled_amount' => null, 'last_update_time' => null])];
  227. $service->syncRealtime();
  228. $this->assertSame(1, $byOrder()['loss']['status']);
  229. $this->assertEquals(-100, $byOrder()['loss']['settled_amount']);
  230. $this->assertSame('2026-11-05 10:00:00', $byOrder()['loss']['last_update_time']);
  231. }
  232. public function test_scheduled_backfill_skips_quietly_until_the_migration_has_run(): void
  233. {
  234. $migration = $this->commissionMigration();
  235. $migration->down();
  236. $this->assertFalse((new ThirdGameCommissionService())->migrated());
  237. $command = new \App\Console\Commands\ThirdGameCommissionBackfill();
  238. $command->setLaravel(app());
  239. $tester = new \Symfony\Component\Console\Tester\CommandTester($command);
  240. $this->assertSame(0, $tester->execute(['--retry-missing' => true]));
  241. $this->assertStringContainsString('跳过', $tester->getDisplay());
  242. $migration->up();
  243. $this->assertTrue((new ThirdGameCommissionService())->migrated());
  244. }
  245. public function test_backfill_leaves_old_snapshots_of_the_other_environment_untouched(): void
  246. {
  247. Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai'));
  248. $migration = $this->commissionMigration();
  249. $migration->down();
  250. $this->insertOrder('own');
  251. $this->insertOrder('foreign', ['player_id' => 'tu0106']);
  252. $migration->up();
  253. $this->fakeQuota([[['platfrom' => 'ag', 'ratio' => '0.10']]]);
  254. $this->assertSame(['updated' => 1, 'remaining' => 0], (new ThirdGameCommissionService())->backfill(false));
  255. $this->assertNull(DB::table('third_game_orders')->where('game_order_id', 'foreign')->value('third_game_commission'));
  256. }
  257. private function commissionMigration(): object
  258. {
  259. return require dirname(__DIR__, 2) . '/database/migrations/2026_11_05_150000_add_third_game_order_commission.php';
  260. }
  261. private function order(string $id, array $extra = []): array
  262. {
  263. return $extra + [
  264. 'player_id' => 'u0106', 'game_order_id' => $id, 'platform' => 'AG', 'currency' => 'CNY',
  265. 'game_type' => 1, 'status' => 1, 'bet_amount' => '100', 'settled_amount' => '-100',
  266. 'bet_time' => '2026-11-05 09:00:00', 'last_update_time' => '2026-11-05 10:00:00',
  267. ];
  268. }
  269. private function insertOrder(string $id, array $extra = []): void
  270. {
  271. DB::table('third_game_orders')->insert($this->order($id, $extra) + ['order_key' => hash('sha256', $id), 'user_id' => 106]);
  272. }
  273. /** @param list<array> $before raw responses (e.g. failures) returned before the daily ratio lists */
  274. private function fakeQuota(array $days, array $before = []): void
  275. {
  276. app()->instance('cache', new CacheRepository(new ArrayStore()));
  277. app()->instance(Factory::class, new Factory());
  278. app()->instance('log', new \Psr\Log\NullLogger());
  279. config(['third_game.api_url' => 'https://supplier.test', 'third_game.sn' => 'test', 'third_game.key' => 'test']);
  280. Http::preventStrayRequests();
  281. $sequence = Http::sequence();
  282. foreach ($before as $response) {
  283. $sequence->push($response);
  284. }
  285. foreach ($days as $ratios) {
  286. $sequence->push(['code' => 10000, 'data' => ['ratios' => $ratios]]);
  287. }
  288. Http::fake(['https://supplier.test/api/server/quota' => $sequence]);
  289. }
  290. }