SyncPerformanceMysqlTest.php 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Services\PromotionFacts;
  4. use App\Services\Reporting\ReportSync;
  5. use App\Support\SyncCommandLock;
  6. use Carbon\Carbon;
  7. use Illuminate\Config\Repository;
  8. use Illuminate\Database\Capsule\Manager;
  9. use Illuminate\Database\Schema\Blueprint;
  10. use Illuminate\Foundation\Application;
  11. use Illuminate\Support\Facades\DB;
  12. use Illuminate\Support\Facades\Facade;
  13. use Illuminate\Support\Facades\Schema;
  14. use PDO;
  15. use PHPUnit\Framework\TestCase;
  16. /** Explicit opt-in only; creates and drops its own database, never loads project .env/providers. */
  17. class SyncPerformanceMysqlTest extends TestCase
  18. {
  19. private ?PDO $control = null;
  20. private string $database;
  21. private array $connection;
  22. protected function setUp(): void
  23. {
  24. $socket = getenv('SYNC_TEST_MYSQL_SOCKET');
  25. if (!$socket) $this->markTestSkipped('Set SYNC_TEST_MYSQL_SOCKET to an isolated test MySQL socket.');
  26. $this->control = new PDO('mysql:unix_socket=' . $socket, getenv('SYNC_TEST_MYSQL_USER') ?: 'root', getenv('SYNC_TEST_MYSQL_PASSWORD') ?: '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
  27. $this->database = 'bot28_sync_test_' . bin2hex(random_bytes(8));
  28. $this->control->exec('CREATE DATABASE `' . $this->database . '` CHARACTER SET utf8mb4');
  29. Facade::clearResolvedInstances();
  30. $app = new Application(dirname(__DIR__, 2));
  31. $app->instance('config', new Repository(['app' => ['timezone' => 'Asia/Shanghai'], 'third_game' => ['currency' => 'CNY']]));
  32. Facade::setFacadeApplication($app);
  33. $db = new Manager($app);
  34. $this->connection = ['driver' => 'mysql', 'unix_socket' => $socket, 'database' => $this->database,
  35. 'username' => getenv('SYNC_TEST_MYSQL_USER') ?: 'root', 'password' => getenv('SYNC_TEST_MYSQL_PASSWORD') ?: '',
  36. 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => 'bot_'];
  37. $db->addConnection($this->connection); $db->setAsGlobal(); $db->bootEloquent();
  38. $app->instance('db', $db->getDatabaseManager()); $app->bind('db.schema', fn () => $db->schema());
  39. Carbon::setTestNow(Carbon::parse('2026-11-02 16:00:00', 'Asia/Shanghai'));
  40. Schema::create('users', function (Blueprint $t) {
  41. $t->id(); $t->string('member_id', 64); $t->integer('from'); $t->unsignedBigInteger('agent_id')->nullable();
  42. });
  43. DB::table('users')->insert(['id' => 1, 'member_id' => 'm1', 'from' => 1]);
  44. Schema::create('balance_logs', function (Blueprint $t) {
  45. $t->id(); $t->string('member_id', 64); $t->integer('type'); $t->string('change_type', 50);
  46. $t->decimal('amount', 24, 10); $t->timestamps();
  47. });
  48. Schema::create('share_bet_facts', function (Blueprint $t) {
  49. $t->id(); $t->unsignedBigInteger('user_id'); $t->string('source', 32); $t->string('source_id', 64);
  50. $t->unique(['source', 'source_id']); $t->dateTime('bet_at'); $t->decimal('bet_amount', 24, 10); $t->decimal('net_profit', 24, 10);
  51. $t->string('currency', 8); $t->boolean('settled'); $t->boolean('voided');
  52. });
  53. foreach (['bets', 'jisu_game_order'] as $source) Schema::create($source, function (Blueprint $t) use ($source) {
  54. $t->id(); $t->string('member_id', 64); $t->decimal('amount', 24, 10);
  55. $t->decimal($source === 'bets' ? 'profit' : 'win_amount', 24, 10)->nullable(); $t->integer('status'); $t->timestamps();
  56. });
  57. }
  58. protected function tearDown(): void
  59. {
  60. if ($this->control) {
  61. DB::disconnect(); DB::disconnect('contender');
  62. $this->control->exec('DROP DATABASE `' . $this->database . '`');
  63. $this->control = null;
  64. Facade::clearResolvedInstances(); Carbon::setTestNow();
  65. }
  66. parent::tearDown();
  67. }
  68. public function test_mysql_daily_aggregates_match_bcmath_including_legacy_and_large_decimal_amounts(): void
  69. {
  70. foreach ([['9000000000000.0000000001', '-9000000000000.0000000001'], ['10.1234567891', '2.1234567891'],
  71. ['50', '100'], ['100', '-25.0000000001'], ['99', '0']] as $index => [$bet, $profit]) {
  72. DB::table('share_bet_facts')->insert(['user_id' => 1, 'source' => 'sport_order', 'source_id' => (string) ($index + 1),
  73. 'bet_at' => '2026-11-02 10:00:00', 'bet_amount' => $bet, 'net_profit' => $profit, 'currency' => 'CNY', 'settled' => 1, 'voided' => 0]);
  74. }
  75. foreach (['bets' => 'profit', 'jisu_game_order' => 'win_amount'] as $source => $payout) {
  76. DB::table($source)->insert(['member_id' => 'm1', 'amount' => '50.0000000001', $payout => '32.1234567891', 'status' => 2, 'created_at' => '2026-11-02 10:00:00']);
  77. }
  78. $facts = new PromotionFacts();
  79. $aggregate = $facts->betting([1], '2026-11-01 00:00:00');
  80. $bcmath = $facts->betting([1], '2026-11-01 00:00:00', null, ['share_bet_facts' => 0]);
  81. $this->assertSame($bcmath, $aggregate);
  82. foreach (['9000000000000.0000000001', '0.1234567891'] as $amount) DB::table('balance_logs')->insert([
  83. 'member_id' => 'm1', 'type' => 1, 'change_type' => '充值', 'amount' => $amount, 'created_at' => '2026-11-02 10:00:00',
  84. ]);
  85. $this->assertSame(['2026-11-02' => '9000000000000.1234567892'], $facts->deposits(['m1'], '2026-11-01 00:00:00'));
  86. DB::table('share_bet_facts')->where('id', 1)->update(['bet_amount' => '-1']);
  87. $this->expectException(\InvalidArgumentException::class);
  88. $facts->betting([1], '2026-11-01 00:00:00');
  89. }
  90. public function test_mysql_indexes_projection_idempotence_and_recent_corrections(): void
  91. {
  92. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_140000_create_reporting_tables.php')->up();
  93. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_130000_add_sync_performance_indexes.php';
  94. $migration->up(); $migration->up();
  95. $this->assertTrue(Schema::hasIndex('reporting_orders', 'reporting_orders_parent_lookup'));
  96. DB::table('bets')->insert(['id' => 1, 'member_id' => 'm1', 'amount' => '100.0000000001', 'profit' => '80', 'status' => 1,
  97. 'created_at' => '2026-11-02 10:00:00', 'updated_at' => '2026-11-02 10:00:00']);
  98. $sync = new ReportSync(); $this->assertSame(0, $sync->sync()['errors']);
  99. $before = DB::table('reporting_orders')->first();
  100. Carbon::setTestNow(now('Asia/Shanghai')->addMinutes(10));
  101. $this->assertSame(0, $sync->sync(true)['errors']);
  102. $this->assertEquals($before, DB::table('reporting_orders')->first());
  103. DB::table('bets')->where('id', 1)->update(['status' => 2, 'updated_at' => now('Asia/Shanghai')->toDateTimeString()]);
  104. $this->assertSame(0, $sync->sync()['errors']);
  105. $this->assertSame(1, (int) DB::table('reporting_orders')->value('settled'));
  106. $this->assertSame('80.0000000000', DB::table('reporting_orders')->value('payout_amount'));
  107. $migration->down();
  108. $this->assertSame(1, DB::table('reporting_orders')->count());
  109. }
  110. public function test_mysql_third_party_cursor_filters_original_ids_in_the_fact_query(): void
  111. {
  112. Schema::create('third_game_orders', function (Blueprint $t) { $t->id(); $t->string('order_key', 64)->unique(); });
  113. foreach ([1, 2] as $id) {
  114. $key = str_repeat((string) $id, 64);
  115. DB::table('third_game_orders')->insert(['id' => $id, 'order_key' => $key]);
  116. DB::table('share_bet_facts')->insert(['user_id' => 1, 'source' => 'third_game_orders', 'source_id' => $key,
  117. 'bet_at' => '2026-11-02 10:00:00', 'bet_amount' => '10', 'net_profit' => '-10', 'currency' => 'CNY', 'settled' => 1, 'voided' => 0]);
  118. }
  119. $result = (new PromotionFacts())->betting([1], '2026-11-01 00:00:00', null, ['third_game_orders' => 1]);
  120. $this->assertSame('10.0000000000', $result['effective']['2026-11-02']);
  121. }
  122. public function test_mysql_aggregation_rejects_missing_payouts_instead_of_ignoring_bad_rows(): void
  123. {
  124. foreach (['5', null] as $payout) DB::table('bets')->insert(['member_id' => 'm1', 'amount' => '10', 'profit' => $payout,
  125. 'status' => 2, 'created_at' => '2026-11-02 10:00:00']);
  126. $this->expectException(\InvalidArgumentException::class);
  127. (new PromotionFacts())->betting([1], '2026-11-01 00:00:00');
  128. }
  129. public function test_mysql_command_lock_excludes_other_connections_and_releases_on_exception(): void
  130. {
  131. app('config')->set('database.connections.contender', $this->connection);
  132. $contender = DB::connection('contender');
  133. $name = 'bot-sync:' . substr(hash('sha256', $this->database . ':bot_:reporting:sync'), 0, 48);
  134. $lock = new SyncCommandLock();
  135. try {
  136. $lock->run('reporting:sync', function () use ($contender, $name) {
  137. $this->assertSame(0, (int) $contender->selectOne('SELECT GET_LOCK(?, 0) AS acquired', [$name])->acquired);
  138. throw new \RuntimeException('expected failure');
  139. });
  140. $this->fail('Expected callback failure');
  141. } catch (\RuntimeException $e) { $this->assertSame('expected failure', $e->getMessage()); }
  142. $this->assertSame(1, (int) $contender->selectOne('SELECT GET_LOCK(?, 0) AS acquired', [$name])->acquired);
  143. $contender->selectOne('SELECT RELEASE_LOCK(?)', [$name]);
  144. $this->assertSame(0, $lock->run('reporting:sync', fn () => 0));
  145. }
  146. }