test_ghs_stock_concurrency.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665
  1. #!/usr/bin/env php
  2. <?php
  3. /**
  4. * Concurrently calls app-ghs stock-changing APIs,
  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 --wastage=2 --hd-purchase=2 --rounds=3
  14. * php scripts/test_ghs_stock_concurrency.php --stock-in-confirm=RK123 --stock-out-cancel=CK123 --purchase-put-in=123
  15. */
  16. require(__DIR__ . '/../vendor/autoload.php');
  17. require(__DIR__ . '/../env.php');
  18. require(__DIR__ . '/../vendor/yiisoft/yii2/Yii.php');
  19. require(__DIR__ . '/../common/config/bootstrap.php');
  20. require(__DIR__ . '/../console/config/bootstrap.php');
  21. $config = yii\helpers\ArrayHelper::merge(
  22. require(__DIR__ . '/../common/config/main.php'),
  23. require(__DIR__ . '/../common/config/main-local.php'),
  24. require(__DIR__ . '/../console/config/main.php'),
  25. require(__DIR__ . '/../console/config/main-local.php')
  26. );
  27. unset($config['components']['request']);
  28. new yii\console\Application($config);
  29. if (!extension_loaded('curl')) {
  30. fwrite(STDERR, "ERROR: PHP curl extension is required.\n");
  31. exit(1);
  32. }
  33. $options = getopt('', ['order::', 'stock-out::', 'wastage::', 'hd-purchase::', 'stock-in-confirm::', 'stock-out-cancel::', 'purchase-put-in::', 'rounds::']);
  34. $orderCount = normalizeNonNegativeInt($options, 'order', 1);
  35. $stockOutCount = normalizeNonNegativeInt($options, 'stock-out', 1);
  36. $wastageCount = normalizeNonNegativeInt($options, 'wastage', 1);
  37. $hdPurchaseCount = normalizeNonNegativeInt($options, 'hd-purchase', 1);
  38. $rounds = normalizePositiveInt($options, 'rounds', 1);
  39. $baseUrl = 'http://api.shop.hzghd.com';
  40. $hdBaseUrl = 'http://api.shop.huaml.com';
  41. $defaultProductIds = [27286, 27282];
  42. $commonHeaders = [
  43. 'Connection: keep-alive',
  44. 'account: 12362',
  45. 'content-type: application/x-www-form-urlencoded;charset=UTF-8',
  46. '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',
  47. 'token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjBfOTMyIn0.eyJpc3MiOiJodHRwczpcL1wvYXBpLnNob3AuaHpnaGQuY29tIiwiYXVkIjoiaHR0cHM6XC9cL2FwaS5zaG9wLmh6Z2hkLmNvbSIsImp0aSI6IjBfOTMyIiwiaWF0IjoxNzc5Mjc1MzgxLCJuYmYiOjE3NzkyNzUzODEsImV4cCI6MTg3Mzg4MzM4MSwidW5pcXVlSWQiOjkzMiwic291cmNlSWQiOjB9.OVuQTjXMvklAFnYdIIRGVR2D5VK14Dp6QX5bDQAUHyE',
  48. 'appVersion: 2',
  49. 'Accept: */*',
  50. 'Sec-Fetch-Site: cross-site',
  51. 'Sec-Fetch-Mode: cors',
  52. 'Sec-Fetch-Dest: empty',
  53. 'Referer: https://servicewechat.com/wx21b7c3ef12082099/devtools/page-frame.html',
  54. 'Accept-Language: zh-CN,zh;q=0.9',
  55. ];
  56. $hdHeaders = [
  57. 'Connection: keep-alive',
  58. 'account: 12362',
  59. 'content-type: application/x-www-form-urlencoded;charset=UTF-8',
  60. 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1 wechatdevtools/2.01.2510260 MicroMessenger/8.0.5 Language/zh_CN webview/ hash/1009552471 sid/xxB6sCFvvu',
  61. 'token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjBfMTM5MSJ9.eyJpc3MiOiJodHRwczpcL1wvYXBpLnNob3AuaHVhbWwuY29tIiwiYXVkIjoiaHR0cHM6XC9cL2FwaS5zaG9wLmh1YW1sLmNvbSIsImp0aSI6IjBfMTM5MSIsImlhdCI6MTc4MDAzNzMxMSwibmJmIjoxNzgwMDM3MzExLCJleHAiOjE4NzQ2NDUzMTEsInVuaXF1ZUlkIjoiMTM5MSIsInNvdXJjZUlkIjowfQ.BgiDsxOd9cktOLsDe8Ud_hcFqzmVwSzKjCVwUdrSXKw',
  62. 'appVersion: 2',
  63. 'Accept: */*',
  64. 'Sec-Fetch-Site: cross-site',
  65. 'Sec-Fetch-Mode: cors',
  66. 'Sec-Fetch-Dest: empty',
  67. 'Referer: https://servicewechat.com/wxe4675bab299a52f7/devtools/page-frame.html',
  68. 'Accept-Language: zh-CN,zh;q=0.9',
  69. ];
  70. $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';
  71. $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';
  72. $hdPurchaseBody = 'getType=2&sendTimeWant=&sendType=0&transType=4&remark=&product=%5B%7B%22productId%22%3A%2227286%22%2C%22bigNum%22%3A1%2C%22smallNum%22%3A0%2C%22classId%22%3A%223322%22%2C%22itemId%22%3A%221009%22%2C%22weight%22%3A%221.20%22%2C%22name%22%3A%22%E8%8B%8F%E9%86%92%22%7D%5D&ghsId=932&hbId=0&wlName=&xj=%22%22&itemTotalAmount=27&version=10&direct=0';
  73. $wastageBody = http_build_query([
  74. 'remark' => 'concurrency-test',
  75. 'product' => json_encode([
  76. ['productId' => '27286', 'bigNum' => 1, 'smallNum' => 0, 'classId' => '3322', 'itemId' => '1009'],
  77. ['productId' => '27282', 'bigNum' => 1, 'smallNum' => 0, 'classId' => '3322', 'itemId' => '1005'],
  78. ], JSON_UNESCAPED_UNICODE),
  79. ]);
  80. $requestDefs = [
  81. 'order' => [
  82. 'method' => 'POST',
  83. 'url' => $baseUrl . '/order/create-order',
  84. 'body' => $orderBody,
  85. 'stockDelta' => [27286 => '2', 27282 => '2'],
  86. ],
  87. 'stock-out' => [
  88. 'method' => 'POST',
  89. 'url' => $baseUrl . '/stock-out/create-order',
  90. 'body' => $stockOutBody,
  91. 'stockDelta' => [27286 => '1', 27282 => '1'],
  92. ],
  93. 'wastage' => [
  94. 'method' => 'POST',
  95. 'url' => $baseUrl . '/wastage/create-order',
  96. 'body' => $wastageBody,
  97. 'stockDelta' => [27286 => '1', 27282 => '1'],
  98. ],
  99. 'hd-purchase' => [
  100. 'method' => 'POST',
  101. 'url' => $hdBaseUrl . '/purchase/create-order',
  102. 'headers' => $hdHeaders,
  103. 'body' => $hdPurchaseBody,
  104. 'stockDelta' => [27286 => '1'],
  105. ],
  106. ];
  107. $requestCounts = ['order' => $orderCount, 'stock-out' => $stockOutCount, 'wastage' => $wastageCount, 'hd-purchase' => $hdPurchaseCount];
  108. addParameterizedRequests($requestDefs, $requestCounts, $options, $baseUrl);
  109. $productIds = collectProductIds($requestDefs, $defaultProductIds);
  110. echo "app-ghs stock concurrency test\n";
  111. echo "Targets: {$baseUrl}, {$hdBaseUrl}\n";
  112. echo "Rounds: {$rounds}, order/create-order: {$orderCount}, stock-out/create-order: {$stockOutCount}, wastage/create-order: {$wastageCount}, app-hd purchase/create-order: {$hdPurchaseCount}\n";
  113. echo "Products: " . implode(', ', $productIds) . "\n";
  114. echo "WARNING: real requests will create real orders and decrease real stock.\n\n";
  115. $overallPass = true;
  116. $overallInvalid = false;
  117. $overallConcurrencyError = false;
  118. $appLogFiles = [
  119. 'app-ghs' => __DIR__ . '/../app-ghs/runtime/logs/app.log',
  120. 'app-hd' => __DIR__ . '/../app-hd/runtime/logs/app.log',
  121. ];
  122. for ($round = 1; $round <= $rounds; $round++) {
  123. echo "========== Round {$round}/{$rounds} ==========\n";
  124. $before = loadStocks($productIds);
  125. printStocks('Before', $before, $productIds);
  126. $currentRequestDefs = $requestDefs;
  127. $priceSummary = '';
  128. $currentRequestDefs['order']['body'] = refreshOrderBodyPrices($orderBody, $priceSummary);
  129. echo "Order request pricing: {$priceSummary}\n";
  130. $requests = buildRequests($currentRequestDefs, $requestCounts);
  131. $logOffsets = getLogOffsets($appLogFiles);
  132. $responses = runConcurrentRequests($requests, $commonHeaders);
  133. $concurrencyErrors = findConcurrencyErrorsFromLogs($appLogFiles, $logOffsets);
  134. foreach ($responses as $response) {
  135. $ok = $response['businessSuccess'] ? 'OK' : 'ERR';
  136. echo sprintf(
  137. "[%s] %-18s HTTP %s %.3fs %s\n",
  138. $ok,
  139. $response['label'],
  140. $response['httpCode'],
  141. $response['totalTime'],
  142. $response['summary']
  143. );
  144. }
  145. if (!empty($concurrencyErrors)) {
  146. echo "Concurrency errors from app log:\n";
  147. foreach ($concurrencyErrors as $errorLine) {
  148. echo " - " . $errorLine . "\n";
  149. }
  150. }
  151. $successCounts = countSuccessfulRequests($responses);
  152. $after = loadStocks($productIds);
  153. $expected = calcExpectedStocks($before, $currentRequestDefs, $successCounts, $productIds);
  154. printStocks('Expected after successful requests', $expected, $productIds);
  155. printStocks('Actual', $after, $productIds);
  156. printDiff($expected, $after, $productIds);
  157. $allBusinessSuccess = count($responses) === array_sum($successCounts);
  158. $stockMatches = stocksMatch($expected, $after, $productIds);
  159. if (!$allBusinessSuccess && !empty($concurrencyErrors)) {
  160. $overallConcurrencyError = true;
  161. echo "Round result: CONCURRENCY_ERROR (business request failed because concurrent stock update hit a database lock/deadlock)\n\n";
  162. continue;
  163. }
  164. if (!$allBusinessSuccess) {
  165. $overallInvalid = true;
  166. echo "Round result: INVALID (one or more business requests failed; this round is not a valid concurrency conclusion)\n\n";
  167. continue;
  168. }
  169. if ($stockMatches) {
  170. echo "Round result: PASS\n\n";
  171. } else {
  172. $overallPass = false;
  173. echo "Round result: FAIL (stock mismatch after all business requests succeeded)\n\n";
  174. }
  175. }
  176. if ($overallConcurrencyError) {
  177. echo "FINAL: CONCURRENCY_ERROR\n";
  178. exit(4);
  179. }
  180. if ($overallInvalid && $overallPass) {
  181. echo "FINAL: INVALID (at least one round had failed business responses)\n";
  182. exit(3);
  183. }
  184. if (!$overallInvalid && $overallPass) {
  185. echo "FINAL: PASS\n";
  186. exit(0);
  187. }
  188. if (!$overallPass) {
  189. echo "FINAL: FAIL\n";
  190. exit(2);
  191. }
  192. exit(3);
  193. function normalizePositiveInt($options, $key, $default)
  194. {
  195. if (!isset($options[$key]) || $options[$key] === false || $options[$key] === '') {
  196. return $default;
  197. }
  198. $value = (int)$options[$key];
  199. if ($value < 1) {
  200. fwrite(STDERR, "ERROR: --{$key} must be a positive integer.\n");
  201. exit(1);
  202. }
  203. return $value;
  204. }
  205. function normalizeNonNegativeInt($options, $key, $default)
  206. {
  207. if (!isset($options[$key]) || $options[$key] === false || $options[$key] === '') {
  208. return $default;
  209. }
  210. $value = (int)$options[$key];
  211. if ($value < 0) {
  212. fwrite(STDERR, "ERROR: --{$key} must be a non-negative integer.\n");
  213. exit(1);
  214. }
  215. return $value;
  216. }
  217. function loadStocks($productIds)
  218. {
  219. $rows = Yii::$app->db->createCommand(
  220. 'select id, stock from xhGhsItem where id in (' . implode(',', array_map('intval', $productIds)) . ')'
  221. )->queryAll();
  222. $stocks = [];
  223. foreach ($rows as $row) {
  224. $stocks[(int)$row['id']] = (string)$row['stock'];
  225. }
  226. foreach ($productIds as $productId) {
  227. if (!array_key_exists($productId, $stocks)) {
  228. throw new RuntimeException("Product {$productId} was not found in xhGhsItem.");
  229. }
  230. }
  231. return $stocks;
  232. }
  233. function buildRequests($requestDefs, $requestCounts)
  234. {
  235. $requests = [];
  236. foreach ($requestCounts as $type => $count) {
  237. if (empty($requestDefs[$type]) || $count <= 0) {
  238. continue;
  239. }
  240. for ($i = 1; $i <= $count; $i++) {
  241. $requests[] = [
  242. 'type' => $type,
  243. 'label' => $type . '#' . $i,
  244. 'method' => isset($requestDefs[$type]['method']) ? $requestDefs[$type]['method'] : 'POST',
  245. 'url' => $requestDefs[$type]['url'],
  246. 'headers' => isset($requestDefs[$type]['headers']) ? $requestDefs[$type]['headers'] : null,
  247. 'body' => isset($requestDefs[$type]['body']) ? $requestDefs[$type]['body'] : '',
  248. ];
  249. }
  250. }
  251. return $requests;
  252. }
  253. function addParameterizedRequests(&$requestDefs, &$requestCounts, $options, $baseUrl)
  254. {
  255. if (!empty($options['stock-in-confirm'])) {
  256. $orderSn = (string)$options['stock-in-confirm'];
  257. $requestDefs['stock-in-confirm'] = [
  258. 'method' => 'GET',
  259. 'url' => $baseUrl . '/stock-in/confirm-order?orderSn=' . rawurlencode($orderSn),
  260. 'body' => '',
  261. 'stockDelta' => loadItemNumDelta('xhGhsStockInOrderItem', $orderSn, -1),
  262. ];
  263. $requestCounts['stock-in-confirm'] = 1;
  264. }
  265. if (!empty($options['stock-out-cancel'])) {
  266. $orderSn = (string)$options['stock-out-cancel'];
  267. $requestDefs['stock-out-cancel'] = [
  268. 'method' => 'GET',
  269. 'url' => $baseUrl . '/stock-out/cancel-order?orderSn=' . rawurlencode($orderSn),
  270. 'body' => '',
  271. 'stockDelta' => loadItemNumDelta('xhGhsStockOutOrderItem', $orderSn, -1),
  272. ];
  273. $requestCounts['stock-out-cancel'] = 1;
  274. }
  275. if (!empty($options['purchase-put-in'])) {
  276. $purchaseId = intval($options['purchase-put-in']);
  277. $orderSn = Yii::$app->db->createCommand('select orderSn from xhGhsCgOrder where id=:id', [':id' => $purchaseId])->queryScalar();
  278. if (!empty($orderSn)) {
  279. $requestDefs['purchase-put-in'] = [
  280. 'method' => 'GET',
  281. 'url' => $baseUrl . '/purchase-order/confirm-put-in?id=' . $purchaseId,
  282. 'body' => '',
  283. 'stockDelta' => loadItemNumDelta('xhGhsCgOrderItem', $orderSn, -1),
  284. ];
  285. $requestCounts['purchase-put-in'] = 1;
  286. }
  287. }
  288. }
  289. function loadItemNumDelta($tableName, $orderSn, $direction)
  290. {
  291. $rows = Yii::$app->db->createCommand(
  292. "select productId, itemNum from {$tableName} where orderSn=:orderSn",
  293. [':orderSn' => $orderSn]
  294. )->queryAll();
  295. $delta = [];
  296. foreach ($rows as $row) {
  297. $productId = intval($row['productId']);
  298. $itemNum = (string)$row['itemNum'];
  299. if ($direction < 0) {
  300. $itemNum = bcmul($itemNum, '-1', 2);
  301. }
  302. $delta[$productId] = isset($delta[$productId]) ? bcadd($delta[$productId], $itemNum, 2) : $itemNum;
  303. }
  304. return $delta;
  305. }
  306. function collectProductIds($requestDefs, $defaultProductIds)
  307. {
  308. $ids = $defaultProductIds;
  309. foreach ($requestDefs as $requestDef) {
  310. foreach (array_keys($requestDef['stockDelta']) as $productId) {
  311. $ids[] = intval($productId);
  312. }
  313. }
  314. $ids = array_values(array_unique(array_filter($ids)));
  315. sort($ids);
  316. return $ids;
  317. }
  318. function refreshOrderBodyPrices($orderBody, &$summary)
  319. {
  320. parse_str($orderBody, $params);
  321. $customId = isset($params['customId']) ? intval($params['customId']) : 0;
  322. $products = isset($params['product']) ? json_decode($params['product'], true) : [];
  323. if (empty($customId) || empty($products) || !is_array($products)) {
  324. $summary = 'keep original prices';
  325. return $orderBody;
  326. }
  327. $custom = \bizGhs\custom\classes\CustomClass::getById($customId);
  328. if (empty($custom)) {
  329. $summary = 'keep original prices; custom not found';
  330. return $orderBody;
  331. }
  332. $productIds = array_unique(array_filter(array_column($products, 'productId')));
  333. $productInfo = \bizGhs\product\classes\ProductClass::getByIds($productIds, null, 'id');
  334. if (empty($productInfo)) {
  335. $summary = 'keep original prices; products not found';
  336. return $orderBody;
  337. }
  338. $priceMap = \bizGhs\custom\classes\CustomClass::$levelPriceKeyMap;
  339. $addPriceMap = \bizGhs\custom\classes\CustomClass::$levelAddPriceKeyMap;
  340. $addPriceMap['risePercent'] = isset($custom['risePercent']) ? $custom['risePercent'] : 0;
  341. $level = isset($custom['level']) ? $custom['level'] : 1;
  342. $modifyPrice = '0';
  343. $parts = [];
  344. foreach ($products as $key => $product) {
  345. $productId = isset($product['productId']) ? intval($product['productId']) : 0;
  346. if (empty($productInfo[$productId])) {
  347. continue;
  348. }
  349. $systemInfo = $productInfo[$productId];
  350. $price = \bizGhs\product\classes\ProductClass::getFinalPrice($systemInfo, $level, $priceMap, $addPriceMap);
  351. $bigNum = isset($product['bigNum']) ? (string)$product['bigNum'] : '0';
  352. $smallNum = isset($product['smallNum']) ? (string)$product['smallNum'] : '0';
  353. $reachDiscountPrice = '0';
  354. $reachNum = isset($systemInfo['reachNum']) ? $systemInfo['reachNum'] : 0;
  355. $reachNumDiscount = isset($systemInfo['reachNumDiscount']) ? $systemInfo['reachNumDiscount'] : 0;
  356. if ($reachNum > 0 && $bigNum >= $reachNum && $price > $reachNumDiscount) {
  357. $price = bcsub($price, $reachNumDiscount, 2);
  358. $reachDiscountPrice = bcmul($reachNumDiscount, $bigNum, 2);
  359. }
  360. $products[$key]['price'] = sprintf('%.2f', (float)$price);
  361. $linePrice = bcmul((string)$products[$key]['price'], $bigNum, 2);
  362. if ($smallNum > 0) {
  363. $ratio = isset($systemInfo['ratio']) && $systemInfo['ratio'] > 0 ? $systemInfo['ratio'] : 1;
  364. $smallItemNum = bcdiv($smallNum, $ratio, 2);
  365. $linePrice = bcadd($linePrice, bcmul((string)$products[$key]['price'], $smallItemNum, 2), 2);
  366. }
  367. $modifyPrice = bcadd($modifyPrice, $linePrice, 2);
  368. $name = isset($systemInfo['name']) ? $systemInfo['name'] : $productId;
  369. $parts[] = $productId . '(' . $name . ')=' . $products[$key]['price'] . 'x' . $bigNum . ($reachDiscountPrice > 0 ? ',reachDiscount=' . $reachDiscountPrice : '');
  370. }
  371. $params['product'] = json_encode($products, JSON_UNESCAPED_UNICODE);
  372. $params['modifyPrice'] = $modifyPrice;
  373. $summary = implode('; ', $parts) . '; modifyPrice=' . $modifyPrice;
  374. return http_build_query($params);
  375. }
  376. function runConcurrentRequests($requests, $headers)
  377. {
  378. $multi = curl_multi_init();
  379. $handles = [];
  380. foreach ($requests as $index => $request) {
  381. $ch = curl_init($request['url']);
  382. $method = isset($request['method']) ? strtoupper($request['method']) : 'POST';
  383. $requestHeaders = !empty($request['headers']) ? $request['headers'] : $headers;
  384. $curlOptions = [
  385. CURLOPT_HTTPHEADER => $requestHeaders,
  386. CURLOPT_RETURNTRANSFER => true,
  387. CURLOPT_ENCODING => '',
  388. CURLOPT_CONNECTTIMEOUT => 10,
  389. CURLOPT_TIMEOUT => 60,
  390. ];
  391. if ($method === 'POST') {
  392. $curlOptions[CURLOPT_POST] = true;
  393. $curlOptions[CURLOPT_POSTFIELDS] = $request['body'];
  394. }
  395. curl_setopt_array($ch, $curlOptions);
  396. curl_multi_add_handle($multi, $ch);
  397. $handles[$index] = [
  398. 'handle' => $ch,
  399. 'request' => $request,
  400. 'startedAt' => microtime(true),
  401. ];
  402. }
  403. $running = null;
  404. do {
  405. $status = curl_multi_exec($multi, $running);
  406. if ($running) {
  407. $selected = curl_multi_select($multi, 1.0);
  408. if ($selected === -1) {
  409. usleep(1000);
  410. }
  411. }
  412. } while ($running && $status === CURLM_OK);
  413. $responses = [];
  414. foreach ($handles as $item) {
  415. $ch = $item['handle'];
  416. $body = curl_multi_getcontent($ch);
  417. $info = curl_getinfo($ch);
  418. $error = curl_error($ch);
  419. $httpCode = isset($info['http_code']) ? (int)$info['http_code'] : 0;
  420. $totalTime = isset($info['total_time']) ? (float)$info['total_time'] : microtime(true) - $item['startedAt'];
  421. $parsed = parseBusinessResponse($body, $httpCode, $error, $item['request']['type']);
  422. $responses[] = [
  423. 'type' => $item['request']['type'],
  424. 'label' => $item['request']['label'],
  425. 'httpCode' => $httpCode,
  426. 'totalTime' => $totalTime,
  427. 'businessSuccess' => $parsed['success'],
  428. 'summary' => $parsed['summary'],
  429. ];
  430. curl_multi_remove_handle($multi, $ch);
  431. curl_close($ch);
  432. }
  433. curl_multi_close($multi);
  434. return $responses;
  435. }
  436. function parseBusinessResponse($body, $httpCode, $curlError, $requestType)
  437. {
  438. if ($curlError !== '') {
  439. return ['success' => false, 'summary' => 'curl_error=' . $curlError];
  440. }
  441. $decoded = json_decode($body, true);
  442. if (is_array($decoded)) {
  443. $code = isset($decoded['code']) ? $decoded['code'] : null;
  444. $msg = isset($decoded['msg']) ? $decoded['msg'] : '';
  445. $data = isset($decoded['data']) && is_array($decoded['data']) ? $decoded['data'] : [];
  446. $respondType = isset($data['respondType']) ? (string)$data['respondType'] : '';
  447. $success = ($httpCode >= 200 && $httpCode < 300 && (string)$code === '1');
  448. if ($requestType === 'order') {
  449. $hasOrderIdentity = !empty($data['id']) || !empty($data['orderSn']);
  450. if ($respondType === 'priceError' || !$hasOrderIdentity) {
  451. $success = false;
  452. }
  453. }
  454. $extra = [];
  455. if ($respondType !== '') {
  456. $extra[] = 'respondType=' . $respondType;
  457. }
  458. if (!empty($data['id'])) {
  459. $extra[] = 'id=' . $data['id'];
  460. }
  461. if (!empty($data['orderSn'])) {
  462. $extra[] = 'orderSn=' . $data['orderSn'];
  463. }
  464. return [
  465. 'success' => $success,
  466. 'summary' => trimText('code=' . json_encode($code, JSON_UNESCAPED_UNICODE) . ' msg=' . (string)$msg . (!empty($extra) ? ' ' . implode(' ', $extra) : ''), 240),
  467. ];
  468. }
  469. return [
  470. 'success' => false,
  471. 'summary' => trimText(preg_replace('/\s+/', ' ', (string)$body), 240),
  472. ];
  473. }
  474. function countSuccessfulRequests($responses)
  475. {
  476. $counts = [];
  477. foreach ($responses as $response) {
  478. if (!isset($counts[$response['type']])) {
  479. $counts[$response['type']] = 0;
  480. }
  481. if ($response['businessSuccess']) {
  482. $counts[$response['type']]++;
  483. }
  484. }
  485. return $counts;
  486. }
  487. function calcExpectedStocks($before, $requestDefs, $successCounts, $productIds)
  488. {
  489. $expected = $before;
  490. foreach ($successCounts as $type => $count) {
  491. foreach ($productIds as $productId) {
  492. $delta = isset($requestDefs[$type]['stockDelta'][$productId])
  493. ? $requestDefs[$type]['stockDelta'][$productId]
  494. : '0';
  495. if ($count > 0 && bccomp($delta, '0', 2) !== 0) {
  496. $expected[$productId] = bcsub($expected[$productId], bcmul($delta, (string)$count, 2), 2);
  497. }
  498. }
  499. }
  500. return $expected;
  501. }
  502. function stocksMatch($expected, $actual, $productIds)
  503. {
  504. foreach ($productIds as $productId) {
  505. if (bccomp((string)$expected[$productId], (string)$actual[$productId], 2) !== 0) {
  506. return false;
  507. }
  508. }
  509. return true;
  510. }
  511. function printStocks($title, $stocks, $productIds)
  512. {
  513. echo $title . ': ';
  514. $parts = [];
  515. foreach ($productIds as $productId) {
  516. $parts[] = $productId . '=' . $stocks[$productId];
  517. }
  518. echo implode(', ', $parts) . "\n";
  519. }
  520. function printDiff($expected, $actual, $productIds)
  521. {
  522. echo 'Diff(actual - expected): ';
  523. $parts = [];
  524. foreach ($productIds as $productId) {
  525. $parts[] = $productId . '=' . bcsub((string)$actual[$productId], (string)$expected[$productId], 2);
  526. }
  527. echo implode(', ', $parts) . "\n";
  528. }
  529. function trimText($text, $maxLength)
  530. {
  531. $text = trim($text);
  532. if (function_exists('mb_strlen') && function_exists('mb_substr')) {
  533. return mb_strlen($text, 'UTF-8') > $maxLength
  534. ? mb_substr($text, 0, $maxLength, 'UTF-8') . '...'
  535. : $text;
  536. }
  537. return strlen($text) > $maxLength ? substr($text, 0, $maxLength) . '...' : $text;
  538. }
  539. function getFileSize($file)
  540. {
  541. clearstatcache(true, $file);
  542. return is_file($file) ? filesize($file) : 0;
  543. }
  544. function getLogOffsets($files)
  545. {
  546. $offsets = [];
  547. foreach ($files as $name => $file) {
  548. $offsets[$name] = getFileSize($file);
  549. }
  550. return $offsets;
  551. }
  552. function findConcurrencyErrorsFromLogs($files, $offsets)
  553. {
  554. $errors = [];
  555. foreach ($files as $name => $file) {
  556. $offset = isset($offsets[$name]) ? $offsets[$name] : 0;
  557. foreach (findConcurrencyErrors(readFileFromOffset($file, $offset)) as $error) {
  558. $errors[] = $name . ': ' . $error;
  559. }
  560. }
  561. return $errors;
  562. }
  563. function readFileFromOffset($file, $offset)
  564. {
  565. if (!is_file($file)) {
  566. return '';
  567. }
  568. $handle = fopen($file, 'rb');
  569. if (!$handle) {
  570. return '';
  571. }
  572. fseek($handle, $offset);
  573. $content = stream_get_contents($handle);
  574. fclose($handle);
  575. return $content === false ? '' : $content;
  576. }
  577. function findConcurrencyErrors($logContent)
  578. {
  579. if ($logContent === '') {
  580. return [];
  581. }
  582. $patterns = [
  583. 'Deadlock found',
  584. 'Serialization failure',
  585. 'Lock wait timeout exceeded',
  586. ];
  587. $lines = preg_split('/\r\n|\r|\n/', $logContent);
  588. $matched = [];
  589. foreach ($lines as $line) {
  590. foreach ($patterns as $pattern) {
  591. if (strpos($line, $pattern) !== false) {
  592. $matched[] = trimText(trim($line), 240);
  593. break;
  594. }
  595. }
  596. }
  597. return array_values(array_unique(array_filter($matched)));
  598. }