ReproduceErrorCommand.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705
  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 68981433 # 使用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. // 解析 headers 获取 Content-Type 和 token
  87. $headers = $this->parseHeaders($requestLog->headers);
  88. $contentType = $this->extractContentType($headers);
  89. $token = $this->extractToken($requestLog->headers);
  90. $this->info("Content-Type: " . ($contentType ?: '未检测到'));
  91. if (!$token) {
  92. $this->warn("未找到 token,将不携带 token 发起请求");
  93. } else {
  94. $this->info("提取到 token: " . substr($token, 0, 10) . "...");
  95. }
  96. // 设置用户会话
  97. if ($requestLog->user_id) {
  98. SessionHelper::sessionLogin($token, $requestLog->user_id);
  99. $this->info("token 设置用户: {$requestLog->user_id} ");
  100. }
  101. // 准备请求数据
  102. $requestData = $this->prepareRequestData($requestLog, $dataSource, $contentType);
  103. if ($requestData === null) {
  104. return 1;
  105. }
  106. $this->info("使用数据源: {$requestData['source']}");
  107. $this->info("检测到数据类型: {$requestData['type']}");
  108. $this->info("请求数据预览:");
  109. if ($requestData['type'] === 'json') {
  110. dump(json_decode($requestData['data'], true));
  111. } else {
  112. $this->line(" 二进制数据长度: " . strlen($requestData['data']) . " 字节");
  113. $this->line(" Base64预览: " . substr(base64_encode($requestData['data']), 0, 100) . "...");
  114. }
  115. // 初始化 HTTP 客户端
  116. $this->initializeHttpClient($timeout);
  117. // 发起请求
  118. $this->info("开始发起请求...");
  119. $response = $this->makeRequest($requestData['data'], $token, $requestData['type']);
  120. if ($response === null) {
  121. return 1;
  122. }
  123. // 输出结果
  124. $this->line("响应内容:");
  125. $this->displayResponse($response);
  126. return 0;
  127. }
  128. /**
  129. * 查找请求记录
  130. *
  131. * @param string $identifier 标识符
  132. * @param string $type 类型
  133. * @return RequestLog|null
  134. */
  135. protected function findRequestLog(string $identifier, string $type): ?RequestLog
  136. {
  137. $query = RequestLog::query();
  138. if ($type === 'auto') {
  139. // 自动检测类型
  140. if (is_numeric($identifier)) {
  141. // 纯数字,优先按 ID 查找
  142. $requestLog = $query->where('id', $identifier)->first();
  143. if ($requestLog) {
  144. return $requestLog;
  145. }
  146. }
  147. // 按 request_unid 查找
  148. $requestLog = RequestLog::query()->where('request_unid', $identifier)->first();
  149. if ($requestLog) {
  150. return $requestLog;
  151. }
  152. // 按 run_unid 查找
  153. $requestLog = RequestLog::query()->where('run_unid', $identifier)->first();
  154. if ($requestLog) {
  155. return $requestLog;
  156. }
  157. return null;
  158. }
  159. // 指定类型查找
  160. switch ($type) {
  161. case 'id':
  162. return $query->where('id', $identifier)->first();
  163. case 'request_unid':
  164. return $query->where('request_unid', $identifier)->first();
  165. case 'run_unid':
  166. return $query->where('run_unid', $identifier)->first();
  167. default:
  168. $this->error("不支持的类型: {$type}");
  169. return null;
  170. }
  171. }
  172. /**
  173. * 解析 headers JSON 字符串
  174. *
  175. * @param string|null $headersJson
  176. * @return array
  177. */
  178. protected function parseHeaders(?string $headersJson): array
  179. {
  180. if (empty($headersJson)) {
  181. return [];
  182. }
  183. try {
  184. $headers = json_decode($headersJson, true);
  185. return is_array($headers) ? $headers : [];
  186. } catch (\Exception $e) {
  187. $this->warn("解析 headers 失败: " . $e->getMessage());
  188. return [];
  189. }
  190. }
  191. /**
  192. * 从 headers 中提取 Content-Type
  193. *
  194. * @param array $headers
  195. * @return string|null
  196. */
  197. protected function extractContentType(array $headers): ?string
  198. {
  199. $contentTypeKeys = ['content-type', 'Content-Type', 'CONTENT-TYPE'];
  200. foreach ($contentTypeKeys as $key) {
  201. if (isset($headers[$key])) {
  202. $contentType = $headers[$key];
  203. // headers 中的值可能是数组
  204. if (is_array($contentType)) {
  205. return $contentType[0] ?? null;
  206. }
  207. return $contentType;
  208. }
  209. }
  210. return null;
  211. }
  212. /**
  213. * 从 headers JSON 中提取 token
  214. *
  215. * @param string|null $headersJson
  216. * @return string|null
  217. */
  218. protected function extractToken(?string $headersJson): ?string
  219. {
  220. $headers = $this->parseHeaders($headersJson);
  221. // 查找 token 字段(可能在不同的键名下)
  222. $tokenKeys = [ 'token', 'Token', 'authorization', 'Authorization' ];
  223. foreach ($tokenKeys as $key) {
  224. if (isset($headers[$key])) {
  225. $tokenValue = $headers[$key];
  226. // headers 中的值可能是数组
  227. if (is_array($tokenValue)) {
  228. return $tokenValue[0] ?? null;
  229. }
  230. return $tokenValue;
  231. }
  232. }
  233. return null;
  234. }
  235. /**
  236. * 准备请求数据
  237. *
  238. * @param RequestLog $requestLog
  239. * @param string $dataSource
  240. * @param string|null $contentType
  241. * @return array|null
  242. */
  243. protected function prepareRequestData(RequestLog $requestLog, string $dataSource, ?string $contentType): ?array
  244. {
  245. // 根据数据源选择策略
  246. if ($dataSource === 'auto') {
  247. // 自动选择:优先使用 post 数据,如果没有则使用 protobuf_json
  248. if (!empty($requestLog->post)) {
  249. return $this->preparePostData($requestLog->post, $contentType);
  250. } elseif (!empty($requestLog->protobuf_json)) {
  251. return $this->prepareProtobufJsonData($requestLog->protobuf_json);
  252. } else {
  253. $this->error("请求记录中既没有 post 数据也没有 protobuf_json 数据");
  254. return null;
  255. }
  256. } elseif ($dataSource === 'post') {
  257. if (empty($requestLog->post)) {
  258. $this->error("请求记录中缺少 post 数据");
  259. return null;
  260. }
  261. return $this->preparePostData($requestLog->post, $contentType);
  262. } elseif ($dataSource === 'protobuf_json') {
  263. if (empty($requestLog->protobuf_json)) {
  264. $this->error("请求记录中缺少 protobuf_json 数据");
  265. return null;
  266. }
  267. return $this->prepareProtobufJsonData($requestLog->protobuf_json);
  268. } else {
  269. $this->error("不支持的数据源: {$dataSource}");
  270. return null;
  271. }
  272. }
  273. /**
  274. * 准备 post 数据
  275. *
  276. * @param string $postData base64 编码的原始请求数据
  277. * @param string|null $contentType
  278. * @return array
  279. */
  280. protected function preparePostData(string $postData, ?string $contentType): array
  281. {
  282. // 解码 base64 数据
  283. $rawData = base64_decode($postData);
  284. // 根据 Content-Type 判断数据类型
  285. if ($contentType && stripos($contentType, 'json') !== false) {
  286. // JSON 格式数据
  287. return [
  288. 'source' => 'post',
  289. 'type' => 'json',
  290. 'data' => $rawData
  291. ];
  292. } else {
  293. // 默认为 protobuf 二进制格式
  294. return [
  295. 'source' => 'post',
  296. 'type' => 'protobuf',
  297. 'data' => $rawData
  298. ];
  299. }
  300. }
  301. /**
  302. * 准备 protobuf_json 数据
  303. *
  304. * @param string $protobufJson
  305. * @return array
  306. */
  307. protected function prepareProtobufJsonData(string $protobufJson): array
  308. {
  309. return [
  310. 'source' => 'protobuf_json',
  311. 'type' => 'json',
  312. 'data' => $protobufJson
  313. ];
  314. }
  315. /**
  316. * 初始化 HTTP 客户端
  317. *
  318. * @param int $timeout
  319. */
  320. protected function initializeHttpClient(int $timeout): void
  321. {
  322. $this->baseUrl = env('UNITTEST_URL', 'http://localhost:8000');
  323. $this->info("目标地址: {$this->baseUrl}");
  324. $this->client = new Client([
  325. 'base_uri' => $this->baseUrl,
  326. 'timeout' => $timeout,
  327. 'http_errors' => false,
  328. 'verify' => false, // 禁用 SSL 验证
  329. ]);
  330. }
  331. /**
  332. * 发起请求
  333. *
  334. * @param string $requestData 请求数据
  335. * @param string|null $token 认证token
  336. * @param string $dataType 数据类型 (json|protobuf)
  337. * @return array|null
  338. */
  339. protected function makeRequest(string $requestData, ?string $token, string $dataType): ?array
  340. {
  341. try {
  342. // 根据数据类型设置不同的 headers
  343. if ($dataType === 'json') {
  344. $headers = [
  345. 'Content-Type' => 'application/json',
  346. 'Accept' => 'application/json'
  347. ];
  348. } else {
  349. // protobuf 二进制格式
  350. $headers = [
  351. 'Content-Type' => 'application/x-protobuf',
  352. 'Accept' => 'application/x-protobuf',
  353. 'Force-Json'=>'1'
  354. ];
  355. }
  356. if ($token) {
  357. $headers['token'] = $token;
  358. }
  359. Log::info('复现请求开始', [
  360. 'url' => $this->baseUrl . '/gameapi',
  361. 'headers' => $headers,
  362. 'body_length' => strlen($requestData),
  363. 'data_type' => $dataType
  364. ]);
  365. $response = $this->client->post('/gameapi', [
  366. 'body' => $requestData,
  367. 'headers' => $headers
  368. ]);
  369. $statusCode = $response->getStatusCode();
  370. $responseHeaders = $response->getHeaders();
  371. $responseBody = $response->getBody()->getContents();
  372. Log::info('复现请求完成', [
  373. 'status_code' => $statusCode,
  374. 'response_length' => strlen($responseBody),
  375. 'data_type' => $dataType
  376. ]);
  377. return [
  378. 'status_code' => $statusCode,
  379. 'headers' => $responseHeaders,
  380. 'body' => $responseBody
  381. ];
  382. } catch (\Exception $e) {
  383. $this->error("请求失败: " . $e->getMessage());
  384. Log::error('复现请求失败', [
  385. 'error' => $e->getMessage(),
  386. 'trace' => $e->getTraceAsString(),
  387. 'data_type' => $dataType
  388. ]);
  389. return null;
  390. }
  391. }
  392. /**
  393. * 显示响应内容
  394. *
  395. * @param array $response 响应数据(包含status_code、headers、body)
  396. */
  397. protected function displayResponse(array $response): void
  398. {
  399. $this->line("状态码: " . $response['status_code']);
  400. // 显示响应头信息
  401. $this->line("响应头:");
  402. foreach ($response['headers'] as $name => $values) {
  403. $value = is_array($values) ? implode(', ', $values) : $values;
  404. $this->line(" {$name}: {$value}");
  405. }
  406. // 从响应头中提取Content-Type
  407. $responseContentType = $this->extractResponseContentType($response['headers']);
  408. $this->line("检测到响应Content-Type: " . ($responseContentType ?: '未知'));
  409. // 根据响应Content-Type决定显示方式
  410. $responseBody = $response['body'];
  411. $bodyLength = strlen($responseBody);
  412. $this->line("响应体长度: {$bodyLength} 字节");
  413. if ($this->isJsonContentType($responseContentType)) {
  414. // JSON 响应
  415. $this->line("按JSON格式解析响应:");
  416. $jsonData = json_decode($responseBody, true);
  417. if ($jsonData !== null) {
  418. dump($jsonData);
  419. } else {
  420. $this->warn("响应Content-Type为JSON但解析失败");
  421. $this->displayRawContent($responseBody);
  422. }
  423. } elseif ($this->isProtobufContentType($responseContentType)) {
  424. // Protobuf 二进制响应
  425. $this->line("按Protobuf格式处理响应:");
  426. $this->line("Base64 编码: " . substr(base64_encode($responseBody), 0, 200) . "...");
  427. // 尝试解析为 JSON(某些情况下服务器可能返回 JSON)
  428. $jsonData = json_decode($responseBody, true);
  429. if ($jsonData !== null) {
  430. $this->line("响应可解析为 JSON:");
  431. dump($jsonData);
  432. } else {
  433. $this->line("响应为二进制 Protobuf 数据,无法直接显示");
  434. }
  435. } elseif ($this->isHtmlContentType($responseContentType)) {
  436. // HTML 响应
  437. $this->line("按HTML格式处理响应:");
  438. $this->displayHtmlContent($responseBody);
  439. } elseif ($this->isTextContentType($responseContentType)) {
  440. // 纯文本响应
  441. $this->line("按文本格式显示响应:");
  442. $this->line($responseBody);
  443. } else {
  444. // 未知类型,尝试智能检测
  445. $this->line("未知Content-Type,尝试智能检测:");
  446. $this->smartDisplayContent($responseBody);
  447. }
  448. }
  449. /**
  450. * 从响应头中提取Content-Type
  451. *
  452. * @param array $headers 响应头数组
  453. * @return string|null
  454. */
  455. protected function extractResponseContentType(array $headers): ?string
  456. {
  457. // 尝试不同的Content-Type键名(大小写不敏感)
  458. $contentTypeKeys = ['Content-Type', 'content-type', 'Content-type', 'CONTENT-TYPE'];
  459. foreach ($contentTypeKeys as $key) {
  460. if (isset($headers[$key])) {
  461. $value = $headers[$key];
  462. // 如果是数组,取第一个值
  463. if (is_array($value)) {
  464. $value = $value[0] ?? '';
  465. }
  466. // 只取分号前的部分(去掉charset等参数)
  467. return trim(explode(';', $value)[0]);
  468. }
  469. }
  470. return null;
  471. }
  472. /**
  473. * 判断是否为JSON内容类型
  474. *
  475. * @param string|null $contentType
  476. * @return bool
  477. */
  478. protected function isJsonContentType(?string $contentType): bool
  479. {
  480. if (!$contentType) {
  481. return false;
  482. }
  483. $jsonTypes = [
  484. 'application/json',
  485. 'text/json',
  486. 'application/ld+json'
  487. ];
  488. return in_array(strtolower($contentType), $jsonTypes);
  489. }
  490. /**
  491. * 判断是否为Protobuf内容类型
  492. *
  493. * @param string|null $contentType
  494. * @return bool
  495. */
  496. protected function isProtobufContentType(?string $contentType): bool
  497. {
  498. if (!$contentType) {
  499. return false;
  500. }
  501. $protobufTypes = [
  502. 'application/x-protobuf',
  503. 'application/protobuf',
  504. 'application/octet-stream'
  505. ];
  506. return in_array(strtolower($contentType), $protobufTypes);
  507. }
  508. /**
  509. * 判断是否为HTML内容类型
  510. *
  511. * @param string|null $contentType
  512. * @return bool
  513. */
  514. protected function isHtmlContentType(?string $contentType): bool
  515. {
  516. if (!$contentType) {
  517. return false;
  518. }
  519. $htmlTypes = [
  520. 'text/html',
  521. 'application/xhtml+xml'
  522. ];
  523. return in_array(strtolower($contentType), $htmlTypes);
  524. }
  525. /**
  526. * 判断是否为文本内容类型
  527. *
  528. * @param string|null $contentType
  529. * @return bool
  530. */
  531. protected function isTextContentType(?string $contentType): bool
  532. {
  533. if (!$contentType) {
  534. return false;
  535. }
  536. return str_starts_with(strtolower($contentType), 'text/');
  537. }
  538. /**
  539. * 显示HTML内容
  540. *
  541. * @param string $content
  542. */
  543. protected function displayHtmlContent(string $content): void
  544. {
  545. // 检查是否包含错误页面标识
  546. if (str_contains($content, '<title>') && str_contains($content, '</title>')) {
  547. preg_match('/<title>(.*?)<\/title>/i', $content, $matches);
  548. $title = $matches[1] ?? '未知';
  549. $this->line("HTML页面标题: {$title}");
  550. }
  551. // 显示部分内容
  552. $preview = substr(strip_tags($content), 0, 500);
  553. $this->line("HTML内容预览: " . $preview . "...");
  554. // 检查是否为错误页面
  555. if (str_contains($content, 'error') || str_contains($content, 'Error') || str_contains($content, 'exception')) {
  556. $this->warn("检测到可能的错误页面");
  557. }
  558. }
  559. /**
  560. * 显示原始内容
  561. *
  562. * @param string $content
  563. */
  564. protected function displayRawContent(string $content): void
  565. {
  566. $this->line("原始响应内容:");
  567. if (strlen($content) > 1000) {
  568. $this->line(substr($content, 0, 1000) . "...");
  569. $this->line("(内容过长,已截断显示)");
  570. } else {
  571. $this->line($content);
  572. }
  573. }
  574. /**
  575. * 智能检测并显示内容
  576. *
  577. * @param string $content
  578. */
  579. protected function smartDisplayContent(string $content): void
  580. {
  581. // 尝试JSON解析
  582. $jsonData = json_decode($content, true);
  583. if ($jsonData !== null) {
  584. $this->line("内容可解析为JSON:");
  585. dump($jsonData);
  586. return;
  587. }
  588. // 检查是否为HTML
  589. if (str_contains($content, '<html') || str_contains($content, '<!DOCTYPE')) {
  590. $this->line("检测到HTML内容:");
  591. $this->displayHtmlContent($content);
  592. return;
  593. }
  594. // 检查是否为二进制数据
  595. if (!mb_check_encoding($content, 'UTF-8')) {
  596. $this->line("检测到二进制数据:");
  597. $this->line("数据长度: " . strlen($content) . " 字节");
  598. $this->line("Base64 编码: " . substr(base64_encode($content), 0, 200) . "...");
  599. return;
  600. }
  601. // 默认按文本显示
  602. $this->line("按文本格式显示:");
  603. $this->displayRawContent($content);
  604. }
  605. /**
  606. * 清空当前日志文件
  607. */
  608. protected function clearLogFiles(): void
  609. {
  610. Logger::clear_log();
  611. Logger::debug('旧的日志已经清理');
  612. }
  613. }