mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-07-02 16:02:48 +08:00
446 lines
14 KiB
PHP
446 lines
14 KiB
PHP
<?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" 环境变量');
|
||
}
|
||
}
|
||
}
|
||
}
|