ReproduceErrorCommand.php 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Module\AppGame\SessionApp;
  4. use App\Module\AppGame\SessionHelper;
  5. use GuzzleHttp\Client;
  6. use Illuminate\Console\Command;
  7. use UCore\Model\RequestLog;
  8. use Illuminate\Support\Facades\Log;
  9. use UCore\Helper\Logger;
  10. /**
  11. * 错误复现命令
  12. *
  13. * 通过 sys_request_logs 表的记录来复现请求,用于调试和错误排查
  14. * 默认使用 post 字段的原始请求数据,根据 headers 中的 Content-Type 自动检测数据类型
  15. * 根据响应的 Content-Type 头智能显示响应内容(JSON、HTML、Protobuf、文本等)
  16. *
  17. * 使用示例:
  18. * php artisan debug:reproduce-error 69211310 # 使用ID查找,自动选择数据源
  19. * php artisan debug:reproduce-error request_1749626545371 # 使用request_unid查找
  20. * php artisan debug:reproduce-error 68973982 --type=request_unid # 明确指定查找类型
  21. * php artisan debug:reproduce-error 68973982 --no-clear-logs # 不清空日志文件
  22. * php artisan debug:reproduce-error 68973982 --data-source=post # 强制使用post数据
  23. * php artisan debug:reproduce-error 68973982 --data-source=protobuf_json # 强制使用protobuf_json数据
  24. *
  25. */
  26. class ReproduceErrorCommand extends Command
  27. {
  28. /**
  29. * 命令签名
  30. *
  31. * @var string
  32. */
  33. protected $signature = 'debug:reproduce-error {identifier : 请求标识符(可以是id、request_unid或run_unid)} {--type=auto : 标识符类型(id|request_unid|run_unid|auto),auto为自动检测} {--timeout=30 : 请求超时时间(秒)} {--no-clear-logs : 运行时不前清空当前日志文件} {--data-source=auto : 数据源(post|protobuf_json|auto),auto为自动选择,优先使用post}';
  34. /**
  35. * 命令描述
  36. *
  37. * @var string
  38. */
  39. protected $description = '通过请求日志记录复现错误请求,用于调试和问题排查。支持运行前清空日志文件,默认使用post字段数据并根据headers自动检测数据类型,根据响应Content-Type智能显示内容';
  40. /**
  41. * HTTP 客户端
  42. *
  43. * @var Client
  44. */
  45. protected Client $client;
  46. /**
  47. * 基础URL
  48. *
  49. * @var string
  50. */
  51. protected string $baseUrl;
  52. /**
  53. * 执行命令
  54. */
  55. public function handle()
  56. {
  57. $identifier = $this->argument('identifier');
  58. $type = $this->option('type');
  59. $timeout = (int)$this->option('timeout');
  60. $noclearLogs = $this->option('no-clear-logs');
  61. $dataSource = $this->option('data-source');
  62. // 如果开启了清空日志选项,则清空日志文件
  63. if ($noclearLogs) {
  64. } else {
  65. $this->clearLogFiles();
  66. }
  67. Log::info("正在使用 debug:reproduce-error 命令重放请求,当前执行命令: 'php artisan debug:reproduce-error {$identifier}' ");
  68. $this->info("开始查找请求记录...");
  69. $this->info("标识符: {$identifier}");
  70. $this->info("类型: {$type}");
  71. $this->info("数据源: {$dataSource}");
  72. // 查找请求记录
  73. $requestLog = $this->findRequestLog($identifier, $type);
  74. if (!$requestLog) {
  75. $this->error("未找到匹配的请求记录");
  76. return 1;
  77. }
  78. $this->info("找到请求记录:");
  79. $this->line(" ID: {$requestLog->id}");
  80. $this->line(" Request UNID: {$requestLog->request_unid}");
  81. $this->line(" Run UNID: {$requestLog->run_unid}");
  82. $this->line(" UserId : {$requestLog->user_id}");
  83. $this->line(" 路径: {$requestLog->path}");
  84. $this->line(" 方法: {$requestLog->method}");
  85. $this->line(" 创建时间: {$requestLog->created_at}");
  86. // 解析并显示请求头信息
  87. $headers = $this->parseHeaders($requestLog->headers);
  88. $this->displayRequestHeaders($headers);
  89. // 提取关键信息
  90. $contentType = $this->extractContentType($headers);
  91. $token = $this->extractToken($requestLog->headers);
  92. $headerAnalysis = $this->analyzeRequestHeaders($headers);
  93. // 设置用户会话
  94. if ($requestLog->user_id) {
  95. SessionHelper::sessionLogin($token, $requestLog->user_id);
  96. $this->info("token 设置用户: {$requestLog->user_id} ");
  97. }
  98. // 准备请求数据(使用头部分析结果)
  99. $requestData = $this->prepareRequestData($requestLog, $dataSource, $contentType, $headerAnalysis);
  100. if ($requestData === null) {
  101. return 1;
  102. }
  103. $this->displayRequestDataInfo($requestData);
  104. // 初始化 HTTP 客户端
  105. $this->initializeHttpClient($timeout);
  106. // 发起请求
  107. $this->info("开始发起请求...");
  108. $originalHeaders = $this->parseHeaders($requestLog->headers);
  109. $response = $this->makeRequest($requestData['data'], $token, $requestData['type'], $originalHeaders);
  110. if ($response === null) {
  111. return 1;
  112. }
  113. // 输出结果
  114. $this->line("响应内容:");
  115. $this->displayResponse($response);
  116. return 0;
  117. }
  118. /**
  119. * 查找请求记录
  120. *
  121. * @param string $identifier 标识符
  122. * @param string $type 类型
  123. * @return RequestLog|null
  124. */
  125. protected function findRequestLog(string $identifier, string $type): ?RequestLog
  126. {
  127. $query = RequestLog::query();
  128. if ($type === 'auto') {
  129. // 自动检测类型
  130. if (is_numeric($identifier)) {
  131. // 纯数字,优先按 ID 查找
  132. $requestLog = $query->where('id', $identifier)->first();
  133. if ($requestLog) {
  134. return $requestLog;
  135. }
  136. }
  137. // 按 request_unid 查找
  138. $requestLog = RequestLog::query()->where('request_unid', $identifier)->first();
  139. if ($requestLog) {
  140. return $requestLog;
  141. }
  142. // 按 run_unid 查找
  143. $requestLog = RequestLog::query()->where('run_unid', $identifier)->first();
  144. if ($requestLog) {
  145. return $requestLog;
  146. }
  147. return null;
  148. }
  149. // 指定类型查找
  150. switch ($type) {
  151. case 'id':
  152. return $query->where('id', $identifier)->first();
  153. case 'request_unid':
  154. return $query->where('request_unid', $identifier)->first();
  155. case 'run_unid':
  156. return $query->where('run_unid', $identifier)->first();
  157. default:
  158. $this->error("不支持的类型: {$type}");
  159. return null;
  160. }
  161. }
  162. /**
  163. * 显示请求头信息
  164. *
  165. * @param array $headers 解析后的请求头数组
  166. */
  167. protected function displayRequestHeaders(array $headers): void
  168. {
  169. if (empty($headers)) {
  170. $this->warn("未找到请求头信息");
  171. return;
  172. }
  173. $this->info("请求头信息:");
  174. foreach ($headers as $name => $values) {
  175. $value = is_array($values) ? implode(', ', $values) : $values;
  176. // 对敏感信息进行脱敏处理
  177. if (in_array(strtolower($name), ['token', 'authorization', 'cookie'])) {
  178. if (strlen($value) > 10) {
  179. $value = substr($value, 0, 10) . '...(已脱敏)';
  180. }
  181. }
  182. $this->line(" {$name}: {$value}");
  183. }
  184. }
  185. /**
  186. * 分析请求头信息
  187. *
  188. * @param array $headers 解析后的请求头数组
  189. * @return array 分析结果
  190. */
  191. protected function analyzeRequestHeaders(array $headers): array
  192. {
  193. $analysis = [
  194. 'content_type' => null,
  195. 'content_length' => null,
  196. 'user_agent' => null,
  197. 'accept' => null,
  198. 'encoding' => null,
  199. 'auth_type' => null,
  200. 'is_ajax' => false,
  201. 'is_mobile' => false,
  202. 'has_custom_headers' => false,
  203. 'security_headers' => [],
  204. 'custom_headers' => []
  205. ];
  206. foreach ($headers as $name => $values) {
  207. $value = is_array($values) ? ($values[0] ?? '') : $values;
  208. $lowerName = strtolower($name);
  209. switch ($lowerName) {
  210. case 'content-type':
  211. $analysis['content_type'] = $value;
  212. if (str_contains($value, 'charset=')) {
  213. $analysis['encoding'] = trim(explode('charset=', $value)[1]);
  214. }
  215. break;
  216. case 'content-length':
  217. $analysis['content_length'] = (int)$value;
  218. break;
  219. case 'user-agent':
  220. $analysis['user_agent'] = $value;
  221. $analysis['is_mobile'] = $this->isMobileUserAgent($value);
  222. break;
  223. case 'accept':
  224. $analysis['accept'] = $value;
  225. break;
  226. case 'authorization':
  227. $analysis['auth_type'] = 'Bearer/Basic';
  228. break;
  229. case 'token':
  230. $analysis['auth_type'] = 'Token';
  231. break;
  232. case 'x-requested-with':
  233. if (strtolower($value) === 'xmlhttprequest') {
  234. $analysis['is_ajax'] = true;
  235. }
  236. break;
  237. default:
  238. // 检测自定义头部
  239. if (str_starts_with($lowerName, 'x-') ||
  240. !in_array($lowerName, ['host', 'connection', 'cache-control', 'pragma', 'accept-encoding', 'accept-language'])) {
  241. $analysis['has_custom_headers'] = true;
  242. $analysis['custom_headers'][$name] = $value;
  243. }
  244. // 检测安全相关头部
  245. if (in_array($lowerName, ['x-csrf-token', 'x-xsrf-token', 'x-frame-options', 'x-content-type-options'])) {
  246. $analysis['security_headers'][$name] = $value;
  247. }
  248. break;
  249. }
  250. }
  251. $this->displayHeaderAnalysis($analysis);
  252. return $analysis;
  253. }
  254. /**
  255. * 显示请求头分析结果
  256. *
  257. * @param array $analysis 分析结果
  258. */
  259. protected function displayHeaderAnalysis(array $analysis): void
  260. {
  261. $this->info("请求头分析:");
  262. if ($analysis['content_type']) {
  263. $this->line(" Content-Type: {$analysis['content_type']}");
  264. }
  265. if ($analysis['content_length']) {
  266. $this->line(" Content-Length: {$analysis['content_length']} 字节");
  267. }
  268. if ($analysis['auth_type']) {
  269. $this->line(" 认证方式: {$analysis['auth_type']}");
  270. }
  271. if ($analysis['is_ajax']) {
  272. $this->line(" 请求类型: AJAX请求");
  273. }
  274. if ($analysis['is_mobile']) {
  275. $this->line(" 客户端: 移动设备");
  276. }
  277. if ($analysis['has_custom_headers']) {
  278. $this->line(" 自定义头部: " . count($analysis['custom_headers']) . " 个");
  279. }
  280. if (!empty($analysis['security_headers'])) {
  281. $this->line(" 安全头部: " . implode(', ', array_keys($analysis['security_headers'])));
  282. }
  283. }
  284. /**
  285. * 检测是否为移动设备User-Agent
  286. *
  287. * @param string $userAgent
  288. * @return bool
  289. */
  290. protected function isMobileUserAgent(string $userAgent): bool
  291. {
  292. $mobileKeywords = ['Mobile', 'Android', 'iPhone', 'iPad', 'Windows Phone', 'BlackBerry'];
  293. foreach ($mobileKeywords as $keyword) {
  294. if (str_contains($userAgent, $keyword)) {
  295. return true;
  296. }
  297. }
  298. return false;
  299. }
  300. /**
  301. * 解析 headers JSON 字符串
  302. *
  303. * @param string|null $headersJson
  304. * @return array
  305. */
  306. protected function parseHeaders(?string $headersJson): array
  307. {
  308. if (empty($headersJson)) {
  309. return [];
  310. }
  311. try {
  312. $headers = json_decode($headersJson, true);
  313. return is_array($headers) ? $headers : [];
  314. } catch (\Exception $e) {
  315. $this->warn("解析 headers 失败: " . $e->getMessage());
  316. return [];
  317. }
  318. }
  319. /**
  320. * 从 headers 中提取 Content-Type
  321. *
  322. * @param array $headers
  323. * @return string|null
  324. */
  325. protected function extractContentType(array $headers): ?string
  326. {
  327. $contentTypeKeys = ['content-type', 'Content-Type', 'CONTENT-TYPE'];
  328. foreach ($contentTypeKeys as $key) {
  329. if (isset($headers[$key])) {
  330. $contentType = $headers[$key];
  331. // headers 中的值可能是数组
  332. if (is_array($contentType)) {
  333. return $contentType[0] ?? null;
  334. }
  335. return $contentType;
  336. }
  337. }
  338. return null;
  339. }
  340. /**
  341. * 从 headers JSON 中提取 token
  342. *
  343. * @param string|null $headersJson
  344. * @return string|null
  345. */
  346. protected function extractToken(?string $headersJson): ?string
  347. {
  348. $headers = $this->parseHeaders($headersJson);
  349. // 查找 token 字段(可能在不同的键名下)
  350. $tokenKeys = [ 'token', 'Token', 'authorization', 'Authorization' ];
  351. foreach ($tokenKeys as $key) {
  352. if (isset($headers[$key])) {
  353. $tokenValue = $headers[$key];
  354. // headers 中的值可能是数组
  355. if (is_array($tokenValue)) {
  356. return $tokenValue[0] ?? null;
  357. }
  358. return $tokenValue;
  359. }
  360. }
  361. return null;
  362. }
  363. /**
  364. * 准备请求数据
  365. *
  366. * @param RequestLog $requestLog
  367. * @param string $dataSource
  368. * @param string|null $contentType
  369. * @param array $headerAnalysis
  370. * @return array|null
  371. */
  372. protected function prepareRequestData(RequestLog $requestLog, string $dataSource, ?string $contentType, array $headerAnalysis = []): ?array
  373. {
  374. // 根据数据源选择策略,结合头部分析结果
  375. if ($dataSource === 'auto') {
  376. // 智能选择:根据头部分析结果决定最佳数据源
  377. $bestSource = $this->determineBestDataSource($requestLog, $headerAnalysis);
  378. $this->info("根据请求头分析,推荐使用数据源: {$bestSource}");
  379. if ($bestSource === 'post' && !empty($requestLog->post)) {
  380. return $this->preparePostData($requestLog->post, $contentType, $headerAnalysis);
  381. } elseif ($bestSource === 'protobuf_json' && !empty($requestLog->protobuf_json)) {
  382. return $this->prepareProtobufJsonData($requestLog->protobuf_json, $headerAnalysis);
  383. } else {
  384. // 回退到原有逻辑
  385. if (!empty($requestLog->post)) {
  386. return $this->preparePostData($requestLog->post, $contentType, $headerAnalysis);
  387. } elseif (!empty($requestLog->protobuf_json)) {
  388. return $this->prepareProtobufJsonData($requestLog->protobuf_json, $headerAnalysis);
  389. } else {
  390. $this->error("请求记录中既没有 post 数据也没有 protobuf_json 数据");
  391. return null;
  392. }
  393. }
  394. } elseif ($dataSource === 'post') {
  395. if (empty($requestLog->post)) {
  396. $this->error("请求记录中缺少 post 数据");
  397. return null;
  398. }
  399. return $this->preparePostData($requestLog->post, $contentType, $headerAnalysis);
  400. } elseif ($dataSource === 'protobuf_json') {
  401. if (empty($requestLog->protobuf_json)) {
  402. $this->error("请求记录中缺少 protobuf_json 数据");
  403. return null;
  404. }
  405. return $this->prepareProtobufJsonData($requestLog->protobuf_json, $headerAnalysis);
  406. } else {
  407. $this->error("不支持的数据源: {$dataSource}");
  408. return null;
  409. }
  410. }
  411. /**
  412. * 根据请求头分析确定最佳数据源
  413. *
  414. * @param RequestLog $requestLog
  415. * @param array $headerAnalysis
  416. * @return string
  417. */
  418. protected function determineBestDataSource(RequestLog $requestLog, array $headerAnalysis): string
  419. {
  420. // 如果Content-Type明确指示JSON,优先使用protobuf_json
  421. if ($headerAnalysis['content_type'] &&
  422. str_contains(strtolower($headerAnalysis['content_type']), 'json')) {
  423. return 'protobuf_json';
  424. }
  425. // 如果Content-Type指示protobuf或二进制,优先使用post
  426. if ($headerAnalysis['content_type'] &&
  427. (str_contains(strtolower($headerAnalysis['content_type']), 'protobuf') ||
  428. str_contains(strtolower($headerAnalysis['content_type']), 'octet-stream'))) {
  429. return 'post';
  430. }
  431. // 如果是AJAX请求,通常是JSON格式
  432. if ($headerAnalysis['is_ajax']) {
  433. return 'protobuf_json';
  434. }
  435. // 默认优先使用post数据
  436. return 'post';
  437. }
  438. /**
  439. * 准备 post 数据
  440. *
  441. * @param string $postData 可能是base64编码的原始数据或JSON格式的数据
  442. * @param string|null $contentType
  443. * @param array $headerAnalysis
  444. * @return array
  445. */
  446. protected function preparePostData(string $postData, ?string $contentType, array $headerAnalysis = []): array
  447. {
  448. // 首先尝试解析为JSON(新的存储格式)
  449. $jsonData = json_decode($postData, true);
  450. if ($jsonData !== null && is_array($jsonData)) {
  451. // 如果是JSON格式,根据Content-Type转换为相应格式
  452. if ($contentType && stripos($contentType, 'application/x-www-form-urlencoded') !== false) {
  453. // 转换为表单格式
  454. $formData = http_build_query($jsonData);
  455. $dataType = 'form';
  456. $rawData = $formData;
  457. } else {
  458. // 保持JSON格式
  459. $dataType = 'json';
  460. $rawData = $postData;
  461. }
  462. $result = [
  463. 'source' => 'post',
  464. 'type' => $dataType,
  465. 'data' => $rawData,
  466. 'analysis' => [
  467. 'original_size' => strlen($postData),
  468. 'processed_size' => strlen($rawData),
  469. 'content_type' => $contentType,
  470. 'detected_type' => $dataType,
  471. 'storage_format' => 'json'
  472. ]
  473. ];
  474. } else {
  475. // 尝试作为base64编码的原始数据处理(旧的存储格式)
  476. $rawData = base64_decode($postData);
  477. // 如果base64解码失败,直接使用原始数据
  478. if ($rawData === false) {
  479. $rawData = $postData;
  480. }
  481. // 根据请求头分析和 Content-Type 判断数据类型
  482. $dataType = $this->determineDataType($contentType, $headerAnalysis, $rawData);
  483. $result = [
  484. 'source' => 'post',
  485. 'type' => $dataType,
  486. 'data' => $rawData,
  487. 'analysis' => [
  488. 'original_size' => strlen($postData),
  489. 'decoded_size' => strlen($rawData),
  490. 'content_type' => $contentType,
  491. 'detected_type' => $dataType,
  492. 'storage_format' => 'base64'
  493. ]
  494. ];
  495. }
  496. // 如果有头部分析信息,添加到结果中
  497. if (!empty($headerAnalysis)) {
  498. $result['header_context'] = [
  499. 'is_ajax' => $headerAnalysis['is_ajax'] ?? false,
  500. 'is_mobile' => $headerAnalysis['is_mobile'] ?? false,
  501. 'auth_type' => $headerAnalysis['auth_type'] ?? null,
  502. 'content_length' => $headerAnalysis['content_length'] ?? null
  503. ];
  504. }
  505. return $result;
  506. }
  507. /**
  508. * 确定数据类型
  509. *
  510. * @param string|null $contentType
  511. * @param array $headerAnalysis
  512. * @param string $rawData
  513. * @return string
  514. */
  515. protected function determineDataType(?string $contentType, array $headerAnalysis, string $rawData): string
  516. {
  517. // 优先根据Content-Type判断
  518. if ($contentType) {
  519. if (stripos($contentType, 'json') !== false) {
  520. return 'json';
  521. }
  522. if (stripos($contentType, 'protobuf') !== false ||
  523. stripos($contentType, 'octet-stream') !== false) {
  524. return 'protobuf';
  525. }
  526. // 处理表单数据
  527. if (stripos($contentType, 'application/x-www-form-urlencoded') !== false) {
  528. return 'form';
  529. }
  530. }
  531. // 根据请求头分析判断
  532. if (!empty($headerAnalysis['is_ajax'])) {
  533. return 'json';
  534. }
  535. // 尝试解析数据内容判断
  536. if ($this->looksLikeJson($rawData)) {
  537. return 'json';
  538. }
  539. // 检查是否看起来像表单数据
  540. if ($this->looksLikeFormData($rawData)) {
  541. return 'form';
  542. }
  543. // 默认为protobuf
  544. return 'protobuf';
  545. }
  546. /**
  547. * 检查数据是否看起来像JSON
  548. *
  549. * @param string $data
  550. * @return bool
  551. */
  552. protected function looksLikeJson(string $data): bool
  553. {
  554. // 检查是否以JSON常见字符开始
  555. $trimmed = trim($data);
  556. if (empty($trimmed)) {
  557. return false;
  558. }
  559. $firstChar = $trimmed[0];
  560. if (in_array($firstChar, ['{', '[', '"'])) {
  561. // 尝试解析JSON
  562. json_decode($trimmed);
  563. return json_last_error() === JSON_ERROR_NONE;
  564. }
  565. return false;
  566. }
  567. /**
  568. * 检查数据是否看起来像表单数据
  569. *
  570. * @param string $data
  571. * @return bool
  572. */
  573. protected function looksLikeFormData(string $data): bool
  574. {
  575. // 检查是否包含表单数据的特征:key=value&key2=value2
  576. $trimmed = trim($data);
  577. if (empty($trimmed)) {
  578. return false;
  579. }
  580. // 检查是否包含等号和可能的&符号
  581. if (strpos($trimmed, '=') !== false) {
  582. // 尝试解析为表单数据
  583. parse_str($trimmed, $parsed);
  584. // 如果解析成功且不为空,则认为是表单数据
  585. return !empty($parsed);
  586. }
  587. return false;
  588. }
  589. /**
  590. * 准备 protobuf_json 数据
  591. *
  592. * @param string $protobufJson
  593. * @param array $headerAnalysis
  594. * @return array
  595. */
  596. protected function prepareProtobufJsonData(string $protobufJson, array $headerAnalysis = []): array
  597. {
  598. $result = [
  599. 'source' => 'protobuf_json',
  600. 'type' => 'json',
  601. 'data' => $protobufJson,
  602. 'analysis' => [
  603. 'data_size' => strlen($protobufJson),
  604. 'detected_type' => 'json'
  605. ]
  606. ];
  607. // 如果有头部分析信息,添加到结果中
  608. if (!empty($headerAnalysis)) {
  609. $result['header_context'] = [
  610. 'is_ajax' => $headerAnalysis['is_ajax'] ?? false,
  611. 'is_mobile' => $headerAnalysis['is_mobile'] ?? false,
  612. 'auth_type' => $headerAnalysis['auth_type'] ?? null
  613. ];
  614. }
  615. return $result;
  616. }
  617. /**
  618. * 显示请求数据信息
  619. *
  620. * @param array $requestData
  621. */
  622. protected function displayRequestDataInfo(array $requestData): void
  623. {
  624. $this->info("请求数据信息:");
  625. $this->line(" 数据源: {$requestData['source']}");
  626. $this->line(" 数据类型: {$requestData['type']}");
  627. // 显示分析信息
  628. if (isset($requestData['analysis'])) {
  629. $analysis = $requestData['analysis'];
  630. $this->line(" 数据分析:");
  631. if (isset($analysis['decoded_size'])) {
  632. $this->line(" 原始大小: {$analysis['original_size']} 字节");
  633. $this->line(" 解码后大小: {$analysis['decoded_size']} 字节");
  634. } elseif (isset($analysis['data_size'])) {
  635. $this->line(" 数据大小: {$analysis['data_size']} 字节");
  636. }
  637. if (isset($analysis['content_type'])) {
  638. $this->line(" Content-Type: {$analysis['content_type']}");
  639. }
  640. if (isset($analysis['detected_type'])) {
  641. $this->line(" 检测类型: {$analysis['detected_type']}");
  642. }
  643. }
  644. // 显示头部上下文信息
  645. if (isset($requestData['header_context'])) {
  646. $context = $requestData['header_context'];
  647. $contextInfo = [];
  648. if ($context['is_ajax']) {
  649. $contextInfo[] = 'AJAX请求';
  650. }
  651. if ($context['is_mobile']) {
  652. $contextInfo[] = '移动设备';
  653. }
  654. if ($context['auth_type']) {
  655. $contextInfo[] = "认证: {$context['auth_type']}";
  656. }
  657. if (!empty($contextInfo)) {
  658. $this->line(" 请求上下文: " . implode(', ', $contextInfo));
  659. }
  660. }
  661. // 显示数据预览
  662. $this->info("请求数据预览:");
  663. if ($requestData['type'] === 'json') {
  664. $jsonData = json_decode($requestData['data'], true);
  665. if ($jsonData !== null) {
  666. dump($jsonData);
  667. } else {
  668. $this->warn("数据类型标记为JSON但解析失败");
  669. $this->line("原始数据: " . substr($requestData['data'], 0, 200) . "...");
  670. }
  671. } elseif ($requestData['type'] === 'form') {
  672. // 表单数据预览
  673. parse_str($requestData['data'], $formData);
  674. if (!empty($formData)) {
  675. $this->line(" 表单数据:");
  676. foreach ($formData as $key => $value) {
  677. $this->line(" {$key}: {$value}");
  678. }
  679. } else {
  680. $this->warn("数据类型标记为表单但解析失败");
  681. $this->line("原始数据: " . substr($requestData['data'], 0, 200) . "...");
  682. }
  683. } else {
  684. $this->line(" 二进制数据长度: " . strlen($requestData['data']) . " 字节");
  685. $this->line(" Base64预览: " . substr(base64_encode($requestData['data']), 0, 100) . "...");
  686. // 尝试检测是否包含可读文本
  687. if (mb_check_encoding($requestData['data'], 'UTF-8')) {
  688. $preview = substr($requestData['data'], 0, 100);
  689. if (ctype_print($preview)) {
  690. $this->line(" 文本预览: {$preview}...");
  691. }
  692. }
  693. }
  694. }
  695. /**
  696. * 初始化 HTTP 客户端
  697. *
  698. * @param int $timeout
  699. */
  700. protected function initializeHttpClient(int $timeout): void
  701. {
  702. $this->baseUrl = env('UNITTEST_URL', 'http://localhost:8000');
  703. $this->info("目标地址: {$this->baseUrl}");
  704. $this->client = new Client([
  705. 'base_uri' => $this->baseUrl,
  706. 'timeout' => $timeout,
  707. 'http_errors' => false,
  708. 'verify' => false, // 禁用 SSL 验证
  709. ]);
  710. }
  711. /**
  712. * 发起请求
  713. *
  714. * @param string $requestData 请求数据
  715. * @param string|null $token 认证token
  716. * @param string $dataType 数据类型 (json|protobuf|form)
  717. * @param array $originalHeaders 原始请求头
  718. * @return array|null
  719. */
  720. protected function makeRequest(string $requestData, ?string $token, string $dataType, array $originalHeaders = []): ?array
  721. {
  722. try {
  723. // 根据数据类型设置不同的 headers 和请求选项
  724. $requestOptions = [];
  725. if ($dataType === 'json') {
  726. $headers = [
  727. 'Content-Type' => 'application/json',
  728. 'Accept' => 'application/json'
  729. ];
  730. $requestOptions['body'] = $requestData;
  731. } elseif ($dataType === 'form') {
  732. $headers = [
  733. 'Content-Type' => 'application/x-www-form-urlencoded',
  734. 'Accept' => 'application/json'
  735. ];
  736. // 对于表单数据,使用form_params而不是body
  737. parse_str($requestData, $formData);
  738. $requestOptions['form_params'] = $formData;
  739. } else {
  740. // protobuf 二进制格式
  741. $headers = [
  742. 'Content-Type' => 'application/x-protobuf',
  743. 'Accept' => 'application/x-protobuf',
  744. 'Force-Json'=>'1'
  745. ];
  746. $requestOptions['body'] = $requestData;
  747. }
  748. // 复制原始请求中的重要头部信息
  749. $importantHeaders = ['x-signature', 'x-timestamp', 'x-nonce', 'user-agent'];
  750. foreach ($importantHeaders as $headerName) {
  751. $value = $this->findHeaderValue($originalHeaders, $headerName);
  752. if ($value !== null) {
  753. $headers[$headerName] = $value;
  754. }
  755. }
  756. // 设置正确的host头(使用目标服务器的host)
  757. $parsedUrl = parse_url($this->baseUrl);
  758. if (isset($parsedUrl['host'])) {
  759. $headers['host'] = $parsedUrl['host'];
  760. }
  761. if ($token) {
  762. $headers['token'] = $token;
  763. }
  764. $requestOptions['headers'] = $headers;
  765. // 根据数据类型确定目标URL
  766. $targetUrl = $dataType === 'form' ? '/thirdParty/webhook/urs/deposit' : '/gameapi';
  767. Log::info('复现请求开始', [
  768. 'url' => $this->baseUrl . $targetUrl,
  769. 'headers' => $headers,
  770. 'body_length' => strlen($requestData),
  771. 'data_type' => $dataType,
  772. 'form_data' => $dataType === 'form' ? $formData ?? [] : null
  773. ]);
  774. $response = $this->client->post($targetUrl, $requestOptions);
  775. $statusCode = $response->getStatusCode();
  776. $responseHeaders = $response->getHeaders();
  777. $responseBody = $response->getBody()->getContents();
  778. Log::info('复现请求完成', [
  779. 'status_code' => $statusCode,
  780. 'response_length' => strlen($responseBody),
  781. 'data_type' => $dataType
  782. ]);
  783. return [
  784. 'status_code' => $statusCode,
  785. 'headers' => $responseHeaders,
  786. 'body' => $responseBody
  787. ];
  788. } catch (\Exception $e) {
  789. $this->error("请求失败: " . $e->getMessage());
  790. Log::error('复现请求失败', [
  791. 'error' => $e->getMessage(),
  792. 'trace' => $e->getTraceAsString(),
  793. 'data_type' => $dataType
  794. ]);
  795. return null;
  796. }
  797. }
  798. /**
  799. * 显示响应内容
  800. *
  801. * @param array $response 响应数据(包含status_code、headers、body)
  802. */
  803. protected function displayResponse(array $response): void
  804. {
  805. $this->line("状态码: " . $response['status_code']);
  806. // 显示响应头信息
  807. $this->line("响应头:");
  808. foreach ($response['headers'] as $name => $values) {
  809. $value = is_array($values) ? implode(', ', $values) : $values;
  810. $this->line(" {$name}: {$value}");
  811. }
  812. // 从响应头中提取Content-Type
  813. $responseContentType = $this->extractResponseContentType($response['headers']);
  814. $this->line("检测到响应Content-Type: " . ($responseContentType ?: '未知'));
  815. // 根据响应Content-Type决定显示方式
  816. $responseBody = $response['body'];
  817. $bodyLength = strlen($responseBody);
  818. $this->line("响应体长度: {$bodyLength} 字节");
  819. if ($this->isJsonContentType($responseContentType)) {
  820. // JSON 响应
  821. $this->line("按JSON格式解析响应:");
  822. $jsonData = json_decode($responseBody, true);
  823. if ($jsonData !== null) {
  824. dump($jsonData);
  825. } else {
  826. $this->warn("响应Content-Type为JSON但解析失败");
  827. $this->displayRawContent($responseBody);
  828. }
  829. } elseif ($this->isProtobufContentType($responseContentType)) {
  830. // Protobuf 二进制响应
  831. $this->line("按Protobuf格式处理响应:");
  832. $this->line("Base64 编码: " . substr(base64_encode($responseBody), 0, 200) . "...");
  833. // 尝试解析为 JSON(某些情况下服务器可能返回 JSON)
  834. $jsonData = json_decode($responseBody, true);
  835. if ($jsonData !== null) {
  836. $this->line("响应可解析为 JSON:");
  837. dump($jsonData);
  838. } else {
  839. $this->line("响应为二进制 Protobuf 数据,无法直接显示");
  840. }
  841. } elseif ($this->isHtmlContentType($responseContentType)) {
  842. // HTML 响应
  843. $this->line("按HTML格式处理响应:");
  844. $this->displayHtmlContent($responseBody);
  845. } elseif ($this->isTextContentType($responseContentType)) {
  846. // 纯文本响应
  847. $this->line("按文本格式显示响应:");
  848. $this->line($responseBody);
  849. } else {
  850. // 未知类型,尝试智能检测
  851. $this->line("未知Content-Type,尝试智能检测:");
  852. $this->smartDisplayContent($responseBody);
  853. }
  854. }
  855. /**
  856. * 从响应头中提取Content-Type
  857. *
  858. * @param array $headers 响应头数组
  859. * @return string|null
  860. */
  861. protected function extractResponseContentType(array $headers): ?string
  862. {
  863. // 尝试不同的Content-Type键名(大小写不敏感)
  864. $contentTypeKeys = ['Content-Type', 'content-type', 'Content-type', 'CONTENT-TYPE'];
  865. foreach ($contentTypeKeys as $key) {
  866. if (isset($headers[$key])) {
  867. $value = $headers[$key];
  868. // 如果是数组,取第一个值
  869. if (is_array($value)) {
  870. $value = $value[0] ?? '';
  871. }
  872. // 只取分号前的部分(去掉charset等参数)
  873. return trim(explode(';', $value)[0]);
  874. }
  875. }
  876. return null;
  877. }
  878. /**
  879. * 判断是否为JSON内容类型
  880. *
  881. * @param string|null $contentType
  882. * @return bool
  883. */
  884. protected function isJsonContentType(?string $contentType): bool
  885. {
  886. if (!$contentType) {
  887. return false;
  888. }
  889. $jsonTypes = [
  890. 'application/json',
  891. 'text/json',
  892. 'application/ld+json'
  893. ];
  894. return in_array(strtolower($contentType), $jsonTypes);
  895. }
  896. /**
  897. * 判断是否为Protobuf内容类型
  898. *
  899. * @param string|null $contentType
  900. * @return bool
  901. */
  902. protected function isProtobufContentType(?string $contentType): bool
  903. {
  904. if (!$contentType) {
  905. return false;
  906. }
  907. $protobufTypes = [
  908. 'application/x-protobuf',
  909. 'application/protobuf',
  910. 'application/octet-stream'
  911. ];
  912. return in_array(strtolower($contentType), $protobufTypes);
  913. }
  914. /**
  915. * 判断是否为HTML内容类型
  916. *
  917. * @param string|null $contentType
  918. * @return bool
  919. */
  920. protected function isHtmlContentType(?string $contentType): bool
  921. {
  922. if (!$contentType) {
  923. return false;
  924. }
  925. $htmlTypes = [
  926. 'text/html',
  927. 'application/xhtml+xml'
  928. ];
  929. return in_array(strtolower($contentType), $htmlTypes);
  930. }
  931. /**
  932. * 判断是否为文本内容类型
  933. *
  934. * @param string|null $contentType
  935. * @return bool
  936. */
  937. protected function isTextContentType(?string $contentType): bool
  938. {
  939. if (!$contentType) {
  940. return false;
  941. }
  942. return str_starts_with(strtolower($contentType), 'text/');
  943. }
  944. /**
  945. * 显示HTML内容
  946. *
  947. * @param string $content
  948. */
  949. protected function displayHtmlContent(string $content): void
  950. {
  951. // 检查是否包含错误页面标识
  952. if (str_contains($content, '<title>') && str_contains($content, '</title>')) {
  953. preg_match('/<title>(.*?)<\/title>/i', $content, $matches);
  954. $title = $matches[1] ?? '未知';
  955. $this->line("HTML页面标题: {$title}");
  956. }
  957. // 显示部分内容
  958. $preview = substr(strip_tags($content), 0, 500);
  959. $this->line("HTML内容预览: " . $preview . "...");
  960. // 检查是否为错误页面
  961. if (str_contains($content, 'error') || str_contains($content, 'Error') || str_contains($content, 'exception')) {
  962. $this->warn("检测到可能的错误页面");
  963. }
  964. }
  965. /**
  966. * 显示原始内容
  967. *
  968. * @param string $content
  969. */
  970. protected function displayRawContent(string $content): void
  971. {
  972. $this->line("原始响应内容:");
  973. if (strlen($content) > 1000) {
  974. $this->line(substr($content, 0, 1000) . "...");
  975. $this->line("(内容过长,已截断显示)");
  976. } else {
  977. $this->line($content);
  978. }
  979. }
  980. /**
  981. * 智能检测并显示内容
  982. *
  983. * @param string $content
  984. */
  985. protected function smartDisplayContent(string $content): void
  986. {
  987. // 尝试JSON解析
  988. $jsonData = json_decode($content, true);
  989. if ($jsonData !== null) {
  990. $this->line("内容可解析为JSON:");
  991. dump($jsonData);
  992. return;
  993. }
  994. // 检查是否为HTML
  995. if (str_contains($content, '<html') || str_contains($content, '<!DOCTYPE')) {
  996. $this->line("检测到HTML内容:");
  997. $this->displayHtmlContent($content);
  998. return;
  999. }
  1000. // 检查是否为二进制数据
  1001. if (!mb_check_encoding($content, 'UTF-8')) {
  1002. $this->line("检测到二进制数据:");
  1003. $this->line("数据长度: " . strlen($content) . " 字节");
  1004. $this->line("Base64 编码: " . substr(base64_encode($content), 0, 200) . "...");
  1005. return;
  1006. }
  1007. // 默认按文本显示
  1008. $this->line("按文本格式显示:");
  1009. $this->displayRawContent($content);
  1010. }
  1011. /**
  1012. * 在头部数组中查找指定头部的值(不区分大小写)
  1013. *
  1014. * @param array $headers 头部数组
  1015. * @param string $headerName 要查找的头部名称
  1016. * @return string|null
  1017. */
  1018. protected function findHeaderValue(array $headers, string $headerName): ?string
  1019. {
  1020. $lowerHeaderName = strtolower($headerName);
  1021. foreach ($headers as $name => $values) {
  1022. if (strtolower($name) === $lowerHeaderName) {
  1023. // 如果值是数组,取第一个值
  1024. if (is_array($values)) {
  1025. return $values[0] ?? null;
  1026. }
  1027. return $values;
  1028. }
  1029. }
  1030. return null;
  1031. }
  1032. /**
  1033. * 清空当前日志文件
  1034. */
  1035. protected function clearLogFiles(): void
  1036. {
  1037. Logger::clear_log();
  1038. Logger::debug('旧的日志已经清理');
  1039. }
  1040. }