test_ghs_stock_concurrency.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. #!/usr/bin/env php
  2. <?php
  3. /**
  4. * Concurrently calls app-ghs order/create-order and stock-out/create-order,
  5. * then compares xhGhsItem.stock with the expected stock after successful calls.
  6. *
  7. * WARNING: this script sends real requests to api.shop.hzghd.com and creates
  8. * real business records. It does not rollback stock, orders, or stock-out rows.
  9. *
  10. * Usage:
  11. * php scripts/test_ghs_stock_concurrency.php
  12. * php scripts/test_ghs_stock_concurrency.php --rounds=10
  13. * php scripts/test_ghs_stock_concurrency.php --order=2 --stock-out=2 --rounds=3
  14. */
  15. require(__DIR__ . '/../vendor/autoload.php');
  16. require(__DIR__ . '/../env.php');
  17. require(__DIR__ . '/../vendor/yiisoft/yii2/Yii.php');
  18. require(__DIR__ . '/../common/config/bootstrap.php');
  19. require(__DIR__ . '/../console/config/bootstrap.php');
  20. $config = yii\helpers\ArrayHelper::merge(
  21. require(__DIR__ . '/../common/config/main.php'),
  22. require(__DIR__ . '/../common/config/main-local.php'),
  23. require(__DIR__ . '/../console/config/main.php'),
  24. require(__DIR__ . '/../console/config/main-local.php')
  25. );
  26. unset($config['components']['request']);
  27. new yii\console\Application($config);
  28. if (!extension_loaded('curl')) {
  29. fwrite(STDERR, "ERROR: PHP curl extension is required.\n");
  30. exit(1);
  31. }
  32. $options = getopt('', ['order::', 'stock-out::', 'rounds::']);
  33. $orderCount = normalizePositiveInt($options, 'order', 1);
  34. $stockOutCount = normalizePositiveInt($options, 'stock-out', 1);
  35. $rounds = normalizePositiveInt($options, 'rounds', 1);
  36. $baseUrl = 'http://api.shop.hzghd.com';
  37. $productIds = [27286, 27282];
  38. $commonHeaders = [
  39. 'Connection: keep-alive',
  40. 'account: 12362',
  41. 'content-type: application/x-www-form-urlencoded;charset=UTF-8',
  42. 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/21A360 Safari/604.1 wechatdevtools/2.01.2510260 MicroMessenger/8.0.5 Language/zh_CN webview/ hash/1237023557 sid/o7x10OLPfZ',
  43. 'token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjBfOTMyIn0.eyJpc3MiOiJodHRwczpcL1wvYXBpLnNob3AuaHpnaGQuY29tIiwiYXVkIjoiaHR0cHM6XC9cL2FwaS5zaG9wLmh6Z2hkLmNvbSIsImp0aSI6IjBfOTMyIiwiaWF0IjoxNzc5Mjc1MzgxLCJuYmYiOjE3NzkyNzUzODEsImV4cCI6MTg3Mzg4MzM4MSwidW5pcXVlSWQiOjkzMiwic291cmNlSWQiOjB9.OVuQTjXMvklAFnYdIIRGVR2D5VK14Dp6QX5bDQAUHyE',
  44. 'appVersion: 2',
  45. 'Accept: */*',
  46. 'Sec-Fetch-Site: cross-site',
  47. 'Sec-Fetch-Mode: cors',
  48. 'Sec-Fetch-Dest: empty',
  49. 'Referer: https://servicewechat.com/wx21b7c3ef12082099/devtools/page-frame.html',
  50. 'Accept-Language: zh-CN,zh;q=0.9',
  51. ];
  52. $orderBody = 'shopId=12986&shopName=&sendType=0&transType=4&sendDate=&sendTimeWant=&historyDate=&sendCost=&customSendCost=0&customId=914&packCost=&product=%5B%7B%22productId%22%3A%2227286%22%2C%22bigNum%22%3A2%2C%22smallNum%22%3A0%2C%22classId%22%3A%223322%22%2C%22itemId%22%3A%221009%22%2C%22price%22%3A%2227.00%22%2C%22remark%22%3A%22%22%7D%2C%7B%22productId%22%3A%2227282%22%2C%22bigNum%22%3A2%2C%22smallNum%22%3A0%2C%22classId%22%3A%223322%22%2C%22itemId%22%3A%221005%22%2C%22price%22%3A%220.30%22%2C%22remark%22%3A%22%22%7D%5D&remark=&payWay=0&needPrint=1&hasPay=2&getStaffId=0&getStaffName=&dealPrice=0&wlName=&reductionRule=0&callErrand=0&weight=1&deliveryRemark=&customName=%E5%BE%AE%E5%8D%87%E6%BC%AB%E8%8A%B1&modifyPrice=54.6&newVersion=1&book=0&xj=%22%22&treeData=%22%22';
  53. $stockOutBody = 'confirmIn=1&date=2026-05-25&remark=123&inShopId=37043&itemInfo=%5B%7B%22productId%22%3A%2227286%22%2C%22bigNum%22%3A1%2C%22smallNum%22%3A0%2C%22name%22%3A%22%E8%8B%8F%E9%86%92%22%7D%2C%7B%22productId%22%3A%2227282%22%2C%22bigNum%22%3A1%2C%22smallNum%22%3A0%2C%22name%22%3A%22%E6%B4%9B%E7%A5%9E%22%7D%5D&action=confirm';
  54. $requestDefs = [
  55. 'order' => [
  56. 'url' => $baseUrl . '/order/create-order',
  57. 'body' => $orderBody,
  58. 'stockDelta' => [27286 => '2', 27282 => '2'],
  59. ],
  60. 'stock-out' => [
  61. 'url' => $baseUrl . '/stock-out/create-order',
  62. 'body' => $stockOutBody,
  63. 'stockDelta' => [27286 => '1', 27282 => '1'],
  64. ],
  65. ];
  66. echo "app-ghs stock concurrency test\n";
  67. echo "Target: {$baseUrl}\n";
  68. echo "Rounds: {$rounds}, order/create-order: {$orderCount}, stock-out/create-order: {$stockOutCount}\n";
  69. echo "Products: " . implode(', ', $productIds) . "\n";
  70. echo "WARNING: real requests will create real orders and decrease real stock.\n\n";
  71. $overallPass = true;
  72. $overallInvalid = false;
  73. $overallConcurrencyError = false;
  74. $appLogFile = __DIR__ . '/../app-ghs/runtime/logs/app.log';
  75. for ($round = 1; $round <= $rounds; $round++) {
  76. echo "========== Round {$round}/{$rounds} ==========\n";
  77. $before = loadStocks($productIds);
  78. printStocks('Before', $before, $productIds);
  79. $currentRequestDefs = $requestDefs;
  80. $priceSummary = '';
  81. $currentRequestDefs['order']['body'] = refreshOrderBodyPrices($orderBody, $priceSummary);
  82. echo "Order request pricing: {$priceSummary}\n";
  83. $requests = buildRequests($currentRequestDefs, $orderCount, $stockOutCount);
  84. $logOffset = getFileSize($appLogFile);
  85. $responses = runConcurrentRequests($requests, $commonHeaders);
  86. $newLog = readFileFromOffset($appLogFile, $logOffset);
  87. $concurrencyErrors = findConcurrencyErrors($newLog);
  88. foreach ($responses as $response) {
  89. $ok = $response['businessSuccess'] ? 'OK' : 'ERR';
  90. echo sprintf(
  91. "[%s] %-18s HTTP %s %.3fs %s\n",
  92. $ok,
  93. $response['label'],
  94. $response['httpCode'],
  95. $response['totalTime'],
  96. $response['summary']
  97. );
  98. }
  99. if (!empty($concurrencyErrors)) {
  100. echo "Concurrency errors from app log:\n";
  101. foreach ($concurrencyErrors as $errorLine) {
  102. echo " - " . $errorLine . "\n";
  103. }
  104. }
  105. $successCounts = countSuccessfulRequests($responses);
  106. $after = loadStocks($productIds);
  107. $expected = calcExpectedStocks($before, $currentRequestDefs, $successCounts, $productIds);
  108. printStocks('Expected after successful requests', $expected, $productIds);
  109. printStocks('Actual', $after, $productIds);
  110. printDiff($expected, $after, $productIds);
  111. $allBusinessSuccess = count($responses) === array_sum($successCounts);
  112. $stockMatches = stocksMatch($expected, $after, $productIds);
  113. if (!$allBusinessSuccess && !empty($concurrencyErrors)) {
  114. $overallConcurrencyError = true;
  115. echo "Round result: CONCURRENCY_ERROR (business request failed because concurrent stock update hit a database lock/deadlock)\n\n";
  116. continue;
  117. }
  118. if (!$allBusinessSuccess) {
  119. $overallInvalid = true;
  120. echo "Round result: INVALID (one or more business requests failed; this round is not a valid concurrency conclusion)\n\n";
  121. continue;
  122. }
  123. if ($stockMatches) {
  124. echo "Round result: PASS\n\n";
  125. } else {
  126. $overallPass = false;
  127. echo "Round result: FAIL (stock mismatch after all business requests succeeded)\n\n";
  128. }
  129. }
  130. if ($overallConcurrencyError) {
  131. echo "FINAL: CONCURRENCY_ERROR\n";
  132. exit(4);
  133. }
  134. if ($overallInvalid && $overallPass) {
  135. echo "FINAL: INVALID (at least one round had failed business responses)\n";
  136. exit(3);
  137. }
  138. if (!$overallInvalid && $overallPass) {
  139. echo "FINAL: PASS\n";
  140. exit(0);
  141. }
  142. if (!$overallPass) {
  143. echo "FINAL: FAIL\n";
  144. exit(2);
  145. }
  146. exit(3);
  147. function normalizePositiveInt($options, $key, $default)
  148. {
  149. if (!isset($options[$key]) || $options[$key] === false || $options[$key] === '') {
  150. return $default;
  151. }
  152. $value = (int)$options[$key];
  153. if ($value < 1) {
  154. fwrite(STDERR, "ERROR: --{$key} must be a positive integer.\n");
  155. exit(1);
  156. }
  157. return $value;
  158. }
  159. function loadStocks($productIds)
  160. {
  161. $rows = Yii::$app->db->createCommand(
  162. 'select id, stock from xhGhsItem where id in (' . implode(',', array_map('intval', $productIds)) . ')'
  163. )->queryAll();
  164. $stocks = [];
  165. foreach ($rows as $row) {
  166. $stocks[(int)$row['id']] = (string)$row['stock'];
  167. }
  168. foreach ($productIds as $productId) {
  169. if (!array_key_exists($productId, $stocks)) {
  170. throw new RuntimeException("Product {$productId} was not found in xhGhsItem.");
  171. }
  172. }
  173. return $stocks;
  174. }
  175. function buildRequests($requestDefs, $orderCount, $stockOutCount)
  176. {
  177. $requests = [];
  178. for ($i = 1; $i <= $orderCount; $i++) {
  179. $requests[] = [
  180. 'type' => 'order',
  181. 'label' => 'order#' . $i,
  182. 'url' => $requestDefs['order']['url'],
  183. 'body' => $requestDefs['order']['body'],
  184. ];
  185. }
  186. for ($i = 1; $i <= $stockOutCount; $i++) {
  187. $requests[] = [
  188. 'type' => 'stock-out',
  189. 'label' => 'stock-out#' . $i,
  190. 'url' => $requestDefs['stock-out']['url'],
  191. 'body' => $requestDefs['stock-out']['body'],
  192. ];
  193. }
  194. return $requests;
  195. }
  196. function refreshOrderBodyPrices($orderBody, &$summary)
  197. {
  198. parse_str($orderBody, $params);
  199. $customId = isset($params['customId']) ? intval($params['customId']) : 0;
  200. $products = isset($params['product']) ? json_decode($params['product'], true) : [];
  201. if (empty($customId) || empty($products) || !is_array($products)) {
  202. $summary = 'keep original prices';
  203. return $orderBody;
  204. }
  205. $custom = \bizGhs\custom\classes\CustomClass::getById($customId);
  206. if (empty($custom)) {
  207. $summary = 'keep original prices; custom not found';
  208. return $orderBody;
  209. }
  210. $productIds = array_unique(array_filter(array_column($products, 'productId')));
  211. $productInfo = \bizGhs\product\classes\ProductClass::getByIds($productIds, null, 'id');
  212. if (empty($productInfo)) {
  213. $summary = 'keep original prices; products not found';
  214. return $orderBody;
  215. }
  216. $priceMap = \bizGhs\custom\classes\CustomClass::$levelPriceKeyMap;
  217. $addPriceMap = \bizGhs\custom\classes\CustomClass::$levelAddPriceKeyMap;
  218. $addPriceMap['risePercent'] = isset($custom['risePercent']) ? $custom['risePercent'] : 0;
  219. $level = isset($custom['level']) ? $custom['level'] : 1;
  220. $modifyPrice = '0';
  221. $parts = [];
  222. foreach ($products as $key => $product) {
  223. $productId = isset($product['productId']) ? intval($product['productId']) : 0;
  224. if (empty($productInfo[$productId])) {
  225. continue;
  226. }
  227. $systemInfo = $productInfo[$productId];
  228. $price = \bizGhs\product\classes\ProductClass::getFinalPrice($systemInfo, $level, $priceMap, $addPriceMap);
  229. $bigNum = isset($product['bigNum']) ? (string)$product['bigNum'] : '0';
  230. $smallNum = isset($product['smallNum']) ? (string)$product['smallNum'] : '0';
  231. $reachDiscountPrice = '0';
  232. $reachNum = isset($systemInfo['reachNum']) ? $systemInfo['reachNum'] : 0;
  233. $reachNumDiscount = isset($systemInfo['reachNumDiscount']) ? $systemInfo['reachNumDiscount'] : 0;
  234. if ($reachNum > 0 && $bigNum >= $reachNum && $price > $reachNumDiscount) {
  235. $price = bcsub($price, $reachNumDiscount, 2);
  236. $reachDiscountPrice = bcmul($reachNumDiscount, $bigNum, 2);
  237. }
  238. $products[$key]['price'] = sprintf('%.2f', (float)$price);
  239. $linePrice = bcmul((string)$products[$key]['price'], $bigNum, 2);
  240. if ($smallNum > 0) {
  241. $ratio = isset($systemInfo['ratio']) && $systemInfo['ratio'] > 0 ? $systemInfo['ratio'] : 1;
  242. $smallItemNum = bcdiv($smallNum, $ratio, 2);
  243. $linePrice = bcadd($linePrice, bcmul((string)$products[$key]['price'], $smallItemNum, 2), 2);
  244. }
  245. $modifyPrice = bcadd($modifyPrice, $linePrice, 2);
  246. $name = isset($systemInfo['name']) ? $systemInfo['name'] : $productId;
  247. $parts[] = $productId . '(' . $name . ')=' . $products[$key]['price'] . 'x' . $bigNum . ($reachDiscountPrice > 0 ? ',reachDiscount=' . $reachDiscountPrice : '');
  248. }
  249. $params['product'] = json_encode($products, JSON_UNESCAPED_UNICODE);
  250. $params['modifyPrice'] = $modifyPrice;
  251. $summary = implode('; ', $parts) . '; modifyPrice=' . $modifyPrice;
  252. return http_build_query($params);
  253. }
  254. function runConcurrentRequests($requests, $headers)
  255. {
  256. $multi = curl_multi_init();
  257. $handles = [];
  258. foreach ($requests as $index => $request) {
  259. $ch = curl_init($request['url']);
  260. curl_setopt_array($ch, [
  261. CURLOPT_POST => true,
  262. CURLOPT_POSTFIELDS => $request['body'],
  263. CURLOPT_HTTPHEADER => $headers,
  264. CURLOPT_RETURNTRANSFER => true,
  265. CURLOPT_ENCODING => '',
  266. CURLOPT_CONNECTTIMEOUT => 10,
  267. CURLOPT_TIMEOUT => 60,
  268. ]);
  269. curl_multi_add_handle($multi, $ch);
  270. $handles[$index] = [
  271. 'handle' => $ch,
  272. 'request' => $request,
  273. 'startedAt' => microtime(true),
  274. ];
  275. }
  276. $running = null;
  277. do {
  278. $status = curl_multi_exec($multi, $running);
  279. if ($running) {
  280. $selected = curl_multi_select($multi, 1.0);
  281. if ($selected === -1) {
  282. usleep(1000);
  283. }
  284. }
  285. } while ($running && $status === CURLM_OK);
  286. $responses = [];
  287. foreach ($handles as $item) {
  288. $ch = $item['handle'];
  289. $body = curl_multi_getcontent($ch);
  290. $info = curl_getinfo($ch);
  291. $error = curl_error($ch);
  292. $httpCode = isset($info['http_code']) ? (int)$info['http_code'] : 0;
  293. $totalTime = isset($info['total_time']) ? (float)$info['total_time'] : microtime(true) - $item['startedAt'];
  294. $parsed = parseBusinessResponse($body, $httpCode, $error, $item['request']['type']);
  295. $responses[] = [
  296. 'type' => $item['request']['type'],
  297. 'label' => $item['request']['label'],
  298. 'httpCode' => $httpCode,
  299. 'totalTime' => $totalTime,
  300. 'businessSuccess' => $parsed['success'],
  301. 'summary' => $parsed['summary'],
  302. ];
  303. curl_multi_remove_handle($multi, $ch);
  304. curl_close($ch);
  305. }
  306. curl_multi_close($multi);
  307. return $responses;
  308. }
  309. function parseBusinessResponse($body, $httpCode, $curlError, $requestType)
  310. {
  311. if ($curlError !== '') {
  312. return ['success' => false, 'summary' => 'curl_error=' . $curlError];
  313. }
  314. $decoded = json_decode($body, true);
  315. if (is_array($decoded)) {
  316. $code = isset($decoded['code']) ? $decoded['code'] : null;
  317. $msg = isset($decoded['msg']) ? $decoded['msg'] : '';
  318. $data = isset($decoded['data']) && is_array($decoded['data']) ? $decoded['data'] : [];
  319. $respondType = isset($data['respondType']) ? (string)$data['respondType'] : '';
  320. $success = ($httpCode >= 200 && $httpCode < 300 && (string)$code === '1');
  321. if ($requestType === 'order') {
  322. $hasOrderIdentity = !empty($data['id']) || !empty($data['orderSn']);
  323. if ($respondType === 'priceError' || !$hasOrderIdentity) {
  324. $success = false;
  325. }
  326. }
  327. $extra = [];
  328. if ($respondType !== '') {
  329. $extra[] = 'respondType=' . $respondType;
  330. }
  331. if (!empty($data['id'])) {
  332. $extra[] = 'id=' . $data['id'];
  333. }
  334. if (!empty($data['orderSn'])) {
  335. $extra[] = 'orderSn=' . $data['orderSn'];
  336. }
  337. return [
  338. 'success' => $success,
  339. 'summary' => trimText('code=' . json_encode($code, JSON_UNESCAPED_UNICODE) . ' msg=' . (string)$msg . (!empty($extra) ? ' ' . implode(' ', $extra) : ''), 240),
  340. ];
  341. }
  342. return [
  343. 'success' => false,
  344. 'summary' => trimText(preg_replace('/\s+/', ' ', (string)$body), 240),
  345. ];
  346. }
  347. function countSuccessfulRequests($responses)
  348. {
  349. $counts = ['order' => 0, 'stock-out' => 0];
  350. foreach ($responses as $response) {
  351. if ($response['businessSuccess']) {
  352. $counts[$response['type']]++;
  353. }
  354. }
  355. return $counts;
  356. }
  357. function calcExpectedStocks($before, $requestDefs, $successCounts, $productIds)
  358. {
  359. $expected = $before;
  360. foreach ($successCounts as $type => $count) {
  361. foreach ($productIds as $productId) {
  362. $delta = isset($requestDefs[$type]['stockDelta'][$productId])
  363. ? $requestDefs[$type]['stockDelta'][$productId]
  364. : '0';
  365. if ($count > 0 && bccomp($delta, '0', 2) !== 0) {
  366. $expected[$productId] = bcsub($expected[$productId], bcmul($delta, (string)$count, 2), 2);
  367. }
  368. }
  369. }
  370. return $expected;
  371. }
  372. function stocksMatch($expected, $actual, $productIds)
  373. {
  374. foreach ($productIds as $productId) {
  375. if (bccomp((string)$expected[$productId], (string)$actual[$productId], 2) !== 0) {
  376. return false;
  377. }
  378. }
  379. return true;
  380. }
  381. function printStocks($title, $stocks, $productIds)
  382. {
  383. echo $title . ': ';
  384. $parts = [];
  385. foreach ($productIds as $productId) {
  386. $parts[] = $productId . '=' . $stocks[$productId];
  387. }
  388. echo implode(', ', $parts) . "\n";
  389. }
  390. function printDiff($expected, $actual, $productIds)
  391. {
  392. echo 'Diff(actual - expected): ';
  393. $parts = [];
  394. foreach ($productIds as $productId) {
  395. $parts[] = $productId . '=' . bcsub((string)$actual[$productId], (string)$expected[$productId], 2);
  396. }
  397. echo implode(', ', $parts) . "\n";
  398. }
  399. function trimText($text, $maxLength)
  400. {
  401. $text = trim($text);
  402. if (function_exists('mb_strlen') && function_exists('mb_substr')) {
  403. return mb_strlen($text, 'UTF-8') > $maxLength
  404. ? mb_substr($text, 0, $maxLength, 'UTF-8') . '...'
  405. : $text;
  406. }
  407. return strlen($text) > $maxLength ? substr($text, 0, $maxLength) . '...' : $text;
  408. }
  409. function getFileSize($file)
  410. {
  411. clearstatcache(true, $file);
  412. return is_file($file) ? filesize($file) : 0;
  413. }
  414. function readFileFromOffset($file, $offset)
  415. {
  416. if (!is_file($file)) {
  417. return '';
  418. }
  419. $handle = fopen($file, 'rb');
  420. if (!$handle) {
  421. return '';
  422. }
  423. fseek($handle, $offset);
  424. $content = stream_get_contents($handle);
  425. fclose($handle);
  426. return $content === false ? '' : $content;
  427. }
  428. function findConcurrencyErrors($logContent)
  429. {
  430. if ($logContent === '') {
  431. return [];
  432. }
  433. $patterns = [
  434. 'Deadlock found',
  435. 'Serialization failure',
  436. 'Lock wait timeout exceeded',
  437. ];
  438. $lines = preg_split('/\r\n|\r|\n/', $logContent);
  439. $matched = [];
  440. foreach ($lines as $line) {
  441. foreach ($patterns as $pattern) {
  442. if (strpos($line, $pattern) !== false) {
  443. $matched[] = trimText(trim($line), 240);
  444. break;
  445. }
  446. }
  447. }
  448. return array_values(array_unique(array_filter($matched)));
  449. }