setName('tools:http:call') ->setDescription('HTTP 调用工具,类似 curl,支持框架特性参数') ->addArgument('url', Argument::OPTIONAL, '请求 URL 或控制器路径(如 /admin/user/index)') ->addOption('url', null, Option::VALUE_OPTIONAL, '请求 URL') ->addOption('method', 'm', Option::VALUE_OPTIONAL, 'HTTP 方法 (GET, POST, PUT, DELETE)', 'GET') ->addOption('data', 'd', Option::VALUE_OPTIONAL, '请求数据(JSON 或 key=value)') ->addOption('body', null, Option::VALUE_OPTIONAL, '请求体(原始数据)') ->addOption('headers', 'H', Option::VALUE_OPTIONAL, '请求头(JSON 格式,如 {"Content-Type":"application/json"})') ->addOption('app', null, Option::VALUE_OPTIONAL, '应用名称(框架特性)') ->addOption('controller', null, Option::VALUE_OPTIONAL, '控制器名称(框架特性)') ->addOption('action', null, Option::VALUE_OPTIONAL, '动作名称(框架特性)') ->addOption('page-data', null, Option::VALUE_NONE, '返回页面 assign 数据(追加 get_page_data=1)') ->addOption('super-token', null, Option::VALUE_OPTIONAL, '超级 Token(框架特性)', 'true') ->addOption('user-id', null, Option::VALUE_OPTIONAL, '用户 ID(框架特性)'); } /** * 执行命令 */ protected function execute(Input $input, Output $output) { $this->startTime = microtime(true); // 检查环境 $this->checkEnvironment($input); // 初始化 HTTP 客户端 $this->httpClient = new Client([ 'base_uri' => $this->getBaseUrl(), 'timeout' => 30, 'verify' => false, // 开发环境跳过 SSL 验证 ]); try { // 构建请求 $request = $this->buildRequest($input); // 执行请求 $response = $this->executeRequest($request, $output); // 输出结果 $this->outputResult($response, $output); } catch (\Exception $e) { $executionTime = round((microtime(true) - $this->startTime) * 1000, 2); $this->outputError($e, $executionTime, $output); } } /** * 检查运行环境 */ protected function checkEnvironment(Input $input): void { // 检查是否使用框架特性参数 $usingFrameworkParams = !empty($input->getOption('app')) || !empty($input->getOption('controller')) || !empty($input->getOption('action')); if ($usingFrameworkParams) { // 检查应用是否运行 $baseUrl = $this->getBaseUrl(); $isLocalhost = (strpos($baseUrl, 'localhost') !== false || strpos($baseUrl, '127.0.0.1') !== false); if ($isLocalhost) { // 尝试检测应用是否运行 $client = new Client([ 'timeout' => 20, 'verify' => false, 'http_errors' => false, ]); try { $client->get($baseUrl); } catch (\GuzzleHttp\Exception\ConnectException $e) { throw new \Exception( "无法连接到应用服务器 ($baseUrl)。请确保应用正在运行(执行 'php think run')" . " 或者设置 'app.app_host' 环境变量。" ); } } } } /** * 获取基础 URL */ protected function getBaseUrl(): string { $appUrl = env('app.app_host', ''); if (empty($appUrl)) { $appUrl = 'http://127.0.0.1:8000'; } return rtrim($appUrl, '/'); } /** * 构建请求 */ protected function buildRequest(Input $input): array { // 获取 URL(优先使用 --url 参数,其次使用位置参数) $url = $input->getOption('url') ?: $input->getArgument('url'); // 如果使用框架特性参数,构建 URL if (empty($url)) { $url = $this->buildUrlFromFrameworkParams($input); } if (empty($url)) { throw new \Exception('请提供 URL 或使用框架特性参数(--app, --controller, --action)'); } if ($input->getOption('page-data') && strpos($url, 'get_page_data=') === false) { $url .= (strpos($url, '?') === false ? '?' : '&') . 'get_page_data=1'; } $request = [ 'url' => $url, 'method' => strtoupper($input->getOption('method')), 'headers' => [], 'body' => null, ]; // 解析请求头 $headers = $input->getOption('headers'); if (!empty($headers)) { $request['headers'] = json_decode($headers, true); if (json_last_error() !== JSON_ERROR_NONE) { throw new \Exception('请求头格式错误:' . json_last_error_msg()); } } // 设置 Accept 头为 JSON $request['headers']['Accept'] = 'application/json'; // 解析请求数据 $data = $input->getOption('data'); $body = $input->getOption('body'); if (!empty($body)) { $request['body'] = $body; } elseif (!empty($data)) { $request['headers']['Content-Type'] = 'application/json'; $request['body'] = $this->normalizeDataToJson($data); } $superToken = $this->resolveSuperToken($input); if (!is_null($superToken)) { $userId = $input->getOption('user-id') ?: AdminConstant::SUPER_ADMIN_ID; $admin = SystemAdmin::find($userId); if (empty($admin)) { throw new \Exception('管理员不存在:' . $userId); } $adminData = $admin->toArray(); unset($adminData['password']); $adminData['expire_time'] = time() + 7200; Cache::store('login')->set($superToken, $adminData, 7200); $request['headers']['Authorization'] = 'Bearer ' . $superToken; } return $request; } protected function normalizeDataToJson(string $raw): string { $raw = trim($raw); if ($raw === '') { return $raw; } while (strlen($raw) >= 2) { $first = $raw[0]; $last = $raw[strlen($raw) - 1]; if (!($first === $last && ($first === '"' || $first === "'"))) { break; } $raw = substr($raw, 1, -1); $raw = trim($raw); } json_decode($raw, true); if (json_last_error() === JSON_ERROR_NONE) { return $raw; } if (strpos($raw, '=') === false) { throw new \Exception('数据格式错误:必须是 JSON 字符串,或 key=value 格式'); } $data = $this->parseKeyValueData($raw); return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } protected function parseKeyValueData(string $raw): array { $parts = preg_split('/[&,\s]+/', trim($raw)) ?: []; $result = []; foreach ($parts as $part) { $part = trim((string) $part); if ($part === '') { continue; } $pos = strpos($part, '='); if ($pos === false) { throw new \Exception('数据格式错误:key=value 格式中存在无效片段:' . $part); } $key = urldecode(trim(substr($part, 0, $pos))); $value = urldecode(substr($part, $pos + 1)); if ($key === '') { throw new \Exception('数据格式错误:key 不能为空'); } $result[$key] = $this->castStringValue($value); } return $result; } protected function castStringValue(string $value) { $value = trim($value); $lower = strtolower($value); if ($lower === 'true') { return true; } if ($lower === 'false') { return false; } if ($lower === 'null') { return null; } if ($value !== '' && ($value[0] === '{' || $value[0] === '[')) { $decoded = json_decode($value, true); if (json_last_error() === JSON_ERROR_NONE) { return $decoded; } } if (preg_match('/^-?\d+$/', $value)) { return (int) $value; } if (is_numeric($value)) { return (float) $value; } return $value; } /** * 从框架特性参数构建 URL */ protected function buildUrlFromFrameworkParams(Input $input): string { $app = $input->getOption('app'); $controller = $input->getOption('controller'); $action = $input->getOption('action'); if (empty($app) || empty($controller) || empty($action)) { return ''; } // 构建路由路径 $path = '/' . $app . '/' . $controller . '/' . $action; return $path; } protected function resolveSuperToken(Input $input): ?string { $raw = $input->getOption('super-token'); $value = is_bool($raw) ? ($raw ? 'true' : 'false') : trim((string) $raw); $lower = strtolower($value); if (in_array($lower, ['false', '0', 'off', 'no'], true)) { return null; } if ($value === '' || in_array($lower, ['true', '1', 'on', 'yes'], true)) { return $this->generateToken(); } return $value; } protected function generateToken(): string { return bin2hex(random_bytes(16)); } /** * 执行 HTTP 请求 */ protected function executeRequest(array $request, Output $output): array { $guzzleOptions = [ 'headers' => $request['headers'], ]; // 如果有请求体,添加到选项中 if ($request['body'] !== null) { $guzzleOptions['body'] = $request['body']; } // 执行请求 $guzzleResponse = $this->httpClient->request($request['method'], $request['url'], $guzzleOptions); // 解析响应 $response = [ 'status' => $guzzleResponse->getStatusCode(), 'headers' => $guzzleResponse->getHeaders(), 'data' => null, ]; // 解析响应体 $body = $guzzleResponse->getBody()->getContents(); $jsonData = json_decode($body, true); if (json_last_error() === JSON_ERROR_NONE) { $response['data'] = $jsonData; } else { $response['data'] = $body; } return $response; } /** * 输出结果 */ protected function outputResult(array $response, Output $output): void { $executionTime = round((microtime(true) - $this->startTime) * 1000, 2); // 构建输出数据 $outputData = [ 'success' => $response['status'] >= 200 && $response['status'] < 300, 'response' => [ 'status' => $response['status'], 'data' => $response['data'], 'headers' => $response['headers'], ], 'execution_time' => $executionTime, 'exception' => null, ]; // 文本模式:输出可读格式 $this->outputTextResult($outputData, $output); } /** * 输出文本格式结果 */ protected function outputTextResult(array $data, Output $output): void { $response = $data['response']; $output->newLine(); $output->info('HTTP 状态: ' . $response['status']); $output->info('执行时间: ' . $data['execution_time'] . 'ms'); $output->newLine(); // 输出响应头 $output->comment('响应头:'); foreach ($response['headers'] as $key => $values) { $output->writeln(' ' . $key . ': ' . implode(', ', $values)); } $output->newLine(); // 输出响应数据 $output->comment('响应数据:'); if (is_string($response['data'])) { $output->writeln(' ' . $response['data']); } else { $output->writeln(' ' . json_encode($response['data'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); } $output->newLine(); } /** * 输出错误信息 */ protected function outputError(\Exception $e, float $executionTime, Output $output): void { $output->error('错误: ' . $e->getMessage()); $output->writeln('执行时间: ' . $executionTime . 'ms'); if (env('app_debug', false)) { $output->writeln('文件: ' . $e->getFile() . ':' . $e->getLine()); // 如果是 Guzzle 连接错误,提供提示 if (strpos($e->getMessage(), 'Failed to connect') !== false) { $baseUrl = $this->getBaseUrl(); $output->comment('提示: 请确保应用正在运行(执行 "php think run")或设置 "app.app_host" 环境变量'); } } } }