payment_provider_config_concurrency.php 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. <?php
  2. // Opt-in isolated MySQL only. Never loads .env or connects to the application's database.
  3. $socket = getenv('PAYMENT_TEST_MYSQL_SOCKET');
  4. if (!$socket) { echo "SKIP: set PAYMENT_TEST_MYSQL_SOCKET to an isolated test server\n"; exit(0); }
  5. if (!preg_match('#^/tmp/b28paycfg-[a-z0-9]+\.sock$#D', $socket)) throw new RuntimeException('Use an isolated /tmp/b28paycfg-*.sock server.');
  6. require dirname(__DIR__, 2) . '/vendor/autoload.php';
  7. use App\Services\Payment\PaymentProviderConfig;
  8. use Illuminate\Config\Repository;
  9. use Illuminate\Database\Capsule\Manager;
  10. use Illuminate\Encryption\Encrypter;
  11. use Illuminate\Foundation\Application;
  12. use Illuminate\Http\Request;
  13. use Illuminate\Support\Facades\DB;
  14. use Illuminate\Support\Facades\Facade;
  15. use Illuminate\Translation\ArrayLoader;
  16. use Illuminate\Translation\Translator;
  17. use Illuminate\Validation\Factory;
  18. use Illuminate\Validation\ValidationException;
  19. $mode = $argv[1] ?? 'parent';
  20. $dir = $argv[2] ?? sys_get_temp_dir() . '/payment-config-barriers-' . bin2hex(random_bytes(5));
  21. $case = $argv[3] ?? 'early';
  22. if ($mode === 'parent') {
  23. mkdir($dir, 0700, true);
  24. $pdo = new PDO('mysql:unix_socket=' . $socket, 'root', '');
  25. $pdo->exec('CREATE DATABASE IF NOT EXISTS bot28_payment_config_test');
  26. }
  27. $app = new Application(dirname(__DIR__, 2));
  28. $app->instance('config', new Repository(['app' => ['locale' => 'zh']]));
  29. $app->instance('request', Request::create('/'));
  30. $app->instance('encrypter', new Encrypter(str_repeat('k', 32), 'AES-256-CBC'));
  31. $app->instance('log', new Psr\Log\NullLogger());
  32. $translator = new Translator(new ArrayLoader(), 'zh');
  33. $app->instance('translator', $translator);
  34. $app->instance('validator', new Factory($translator, $app));
  35. Facade::setFacadeApplication($app);
  36. $db = new Manager($app);
  37. $db->addConnection(['driver' => 'mysql', 'unix_socket' => $socket, 'database' => 'bot28_payment_config_test', 'username' => 'root', 'password' => '', 'prefix' => 'bot_']);
  38. $db->setAsGlobal(); $db->bootEloquent();
  39. $app->instance('db', $db->getDatabaseManager());
  40. DB::statement('SET SESSION innodb_lock_wait_timeout = 8');
  41. DB::statement('SET SESSION TRANSACTION ISOLATION LEVEL ' . ($case === 'late' ? 'READ COMMITTED' : 'REPEATABLE READ'));
  42. function awaitFile(string $file): void
  43. {
  44. $deadline = microtime(true) + 10;
  45. while (!file_exists($file)) {
  46. if (microtime(true) > $deadline) throw new RuntimeException('Timed out waiting for ' . basename($file));
  47. usleep(10000);
  48. }
  49. }
  50. function check($actual, $expected, string $label): void
  51. {
  52. if ($actual !== $expected) throw new RuntimeException($label . ': ' . json_encode($actual));
  53. }
  54. if ($mode === 'reader') {
  55. touch($dir . '/reader-started');
  56. if ($case === 'early') DB::beginTransaction();
  57. DB::table('payment_orders')->insert(['type' => 1, 'channel' => 'JDpay', 'status' => 1]);
  58. $snapshot = PaymentProviderConfig::snapshot('jd');
  59. touch($dir . '/reader-ready');
  60. if ($case === 'early') { awaitFile($dir . '/release'); DB::commit(); }
  61. file_put_contents($dir . '/reader-result', $snapshot['mch_id']);
  62. exit(0);
  63. }
  64. if ($mode === 'writer') {
  65. touch($dir . '/writer-started');
  66. try {
  67. if ($case === 'late') DB::beginTransaction();
  68. (new PaymentProviderConfig())->save('jd', ['mch_id' => 'merchant-B', 'key' => 'secret-B']);
  69. if ($case === 'late') { touch($dir . '/writer-ready'); awaitFile($dir . '/release'); DB::commit(); }
  70. file_put_contents($dir . '/writer-result', 'saved');
  71. } catch (ValidationException $e) {
  72. file_put_contents($dir . '/writer-result', 'blocked');
  73. }
  74. exit(0);
  75. }
  76. foreach (['early', 'late'] as $case) {
  77. $barrier = $dir . '/' . $case;
  78. mkdir($barrier, 0700);
  79. foreach (['operation_audits', 'payment_orders', 'payment_providers'] as $table) $db->schema()->dropIfExists($table);
  80. DB::statement('CREATE TABLE bot_payment_providers (id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,code VARCHAR(32) UNIQUE,status TINYINT DEFAULT 1,config TEXT NULL,created_at DATETIME NULL,updated_at DATETIME NULL) ENGINE=InnoDB');
  81. DB::statement('CREATE TABLE bot_payment_orders (id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,type INT,channel VARCHAR(32),status INT) ENGINE=InnoDB');
  82. DB::statement('CREATE TABLE bot_operation_audits (id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,resource_type VARCHAR(64),resource_id BIGINT NULL,action VARCHAR(32),changes JSON,operator_id BIGINT NULL,operator_name VARCHAR(64),created_at DATETIME NULL,updated_at DATETIME NULL) ENGINE=InnoDB');
  83. DB::table('payment_providers')->insert(['code' => 'jd']);
  84. (new PaymentProviderConfig())->save('jd', ['mch_id' => 'merchant-A', 'key' => 'secret-A']);
  85. $start = function (string $worker) use ($barrier, $case) {
  86. $process = proc_open([PHP_BINARY, __FILE__, $worker, $barrier, $case], [0 => ['pipe', 'r'], 1 => ['file', $barrier . '/' . $worker . '.log', 'a'], 2 => ['file', $barrier . '/' . $worker . '.log', 'a']], $pipes);
  87. if (!is_resource($process)) throw new RuntimeException('Cannot start test worker.');
  88. fclose($pipes[0]);
  89. return $process;
  90. };
  91. $workers = [];
  92. try {
  93. $first = $case === 'early' ? 'reader' : 'writer';
  94. $second = $case === 'early' ? 'writer' : 'reader';
  95. $workers[] = $start($first);
  96. awaitFile($barrier . '/' . $first . '-ready');
  97. $workers[] = $start($second);
  98. awaitFile($barrier . '/' . $second . '-started');
  99. usleep(200000);
  100. check(file_exists($barrier . '/' . $second . '-result'), false, 'Concurrent operation waits for configuration lock');
  101. } finally {
  102. touch($barrier . '/release');
  103. foreach ($workers as $worker) check(proc_close($worker), 0, 'Worker exits successfully; inspect ' . $barrier . ' on failure');
  104. }
  105. check(file_get_contents($barrier . '/reader-result'), $case === 'early' ? 'merchant-A' : 'merchant-B', 'Payment uses a consistent committed configuration');
  106. check(file_get_contents($barrier . '/writer-result'), $case === 'early' ? 'blocked' : 'saved', 'Pending-order protection survives concurrent transactions');
  107. echo 'PASS ' . $case . " configuration/transaction race\n";
  108. }