Files
ulthon_admin/extend/base/common/command/tools/http/ToolsHttpCallBase.php
2026-03-26 20:22:34 +08:00

446 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace base\common\command\tools\http;
use app\admin\model\SystemAdmin;
use app\common\console\Command;
use app\common\constants\AdminConstant;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use think\console\Input;
use think\console\input\Argument;
use think\console\input\Option;
use think\console\Output;
use think\facade\Cache;
/**
* tools:http:call 命令基类
* 类似 curl 的 HTTP 调用功能,支持框架特性参数
*/
class ToolsHttpCallBase extends Command
{
/**
* Guzzle HTTP 客户端
* @var Client
*/
protected $httpClient;
/**
* 执行开始时间(毫秒)
* @var float
*/
protected $startTime;
/**
* 配置命令
*/
protected function configure()
{
parent::configure();
$this->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" 环境变量');
}
}
}
}