Files
MrleeAdmin/backend/app/service/core/terminal/Terminal.php
T

834 lines
27 KiB
PHP
Raw Normal View History

2026-08-31 11:33:59 +08:00
<?php
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\service\core\terminal;
use app\enum\terminal\CommandEvent;
use core\tool\Sse;
use core\tool\Util;
use Illuminate\Filesystem\Filesystem;
/**
* Terminal 执行终端命令并通过 SSE 实时输出结果。
*
* @author Mr.April
* @since 1.0
*/
final class Terminal
{
/**
* 插件临时目录基础路径
*/
const RUNTIME_PLUGIN_PATH = 'runtime/install/plugin';
/**
* 插件终端输出目录
*/
const PLUGIN_TERMINAL_PATH = 'runtime/install/plugin/terminal';
/**
* @var string 当前执行的命令 $command 的 key
*/
protected string $commandKey = '';
/**
* @var array proc_open 的参数
*/
protected array $descriptorsSpec = [];
/**
* @var resource|bool proc_open 返回的 resource
*/
protected $process = false;
/**
* @var array proc_open 的管道
*/
protected array $pipes = [];
/**
* @var int proc执行状态:0=未执行,1=执行中,2=执行完毕
*/
protected int $procStatusMark = 0;
/**
* @var array proc执行状态数据
*/
protected array $procStatusData = [];
/**
* @var string 命令在前台的uuid
*/
protected string $uuid = '';
/**
* @var string 扩展信息
*/
protected string $extend = '';
/**
* @var string 命令执行输出文件
*/
protected string $outputFile = '';
/**
* @var string 命令执行实时输出内容
*/
protected string $outputContent = '';
/**
* @var array 命令变量替换
*/
protected array $commandVariables = [];
/**
* @var array 当前执行的命令配置
*/
protected array $commandConfig = [];
/**
* @var array 拦截器映射
*/
protected static array $interceptMap = [
'install' => 'app\service\core\terminal\intercept\InstallIntercept',
'build' => 'app\service\core\terminal\intercept\BuildIntercept',
'common' => 'app\service\core\terminal\intercept\CommonIntercept',
];
/**
* @var int 进程退出码
*/
protected int $processExitCode = 0;
/**
* 私有构造函数,防止直接实例化
*
* @param string $uuid 会话UUID
* @param string $extend 扩展信息
*/
private function __construct(string $uuid = '', string $extend = '')
{
$this->uuid = $uuid;
$this->extend = $extend;
// 初始化命令变量(先初始化变量,再使用变量)
$this->initCommandVariables();
// 初始化输出目录和文件
$this->initOutputFile();
// 初始化管道描述符
$this->initDescriptors();
}
/**
* 初始化输出文件
*/
protected function initOutputFile(): void
{
$config = config('terminal.execution', []);
$outputDir = $config['output_dir'] ?? base_path(self::PLUGIN_TERMINAL_PATH);
$outputFile = $config['output_file'] ?? 'exec.log';
// 替换路径中的变量
$outputDir = $this->replaceCommandVariables($outputDir);
$this->outputFile = $outputDir . DIRECTORY_SEPARATOR . $outputFile;
$filesystem = new Filesystem();
if (!is_dir($outputDir)) {
$filesystem->makeDirectory($outputDir, 0755, true);
}
$filesystem->put($this->outputFile, '');
}
/**
* 初始化管道描述符
*/
protected function initDescriptors(): void
{
/**
* 命令执行结果输出到文件而不是管道
* 因为输出到管道时有延迟,而文件虽然需要频繁读取和对比内容,但是输出实时的
*/
$this->descriptorsSpec = [
0 => ['pipe', 'r'], // 标准输入
1 => ['file', $this->outputFile, 'w'], // 标准输出
2 => ['file', $this->outputFile, 'w'], // 标准错误
];
}
/**
* 初始化命令变量
*/
protected function initCommandVariables(): void
{
// 后端根目录(server目录)
$backendRoot = base_path();
// 项目根目录(包含server、admin、web等目录的父目录)
$projectRoot = dirname($backendRoot);
// 检查是否在命令行环境下执行
if (php_sapi_name() === 'cli') {
// 在命令行环境下,使用默认值
$platform = 'h5';
$env = 'production';
} else {
// 在Web环境下,使用request()函数获取参数
$platform = request()->input('platform', 'h5');
$env = request()->input('env', 'production');
}
$this->commandVariables = [
'%PLATFORM%' => $platform, // Uni-app平台变量
'%ENV%' => $env, // 环境变量
'{package_manager}' => config('terminal.npm_package_manager', 'npm'), // 包管理器
'{backend_root}' => $backendRoot, // 后端根目录
'{project_root}' => $projectRoot, // 项目根目录
];
}
/**
* 获取解析后的命令键(去掉包管理器后缀)
*
* @param string $key 原始命令键
*
* @return string
*/
protected function getResolvedCommandKey(string $key): string
{
$keyParts = explode('.', $key);
// 检查是否是安装或构建命令,尝试去掉包管理器后缀
// 注意:install.server 是后端命令,不使用包管理器
if (count($keyParts) > 2 && (in_array($keyParts[0], ['install', 'build']) && $keyParts[1] !== 'server')) {
// 重新构建命令键,去掉最后一个部分(包管理器)
return implode('.', array_slice($keyParts, 0, -1));
}
// 对于其他命令(如debug、custom等),也尝试去掉包管理器后缀
if (count($keyParts) > 1) {
// 检查最后一个部分是否是包管理器
$lastPart = end($keyParts);
$packageManagers = config('terminal.package_managers', []);
if (isset($packageManagers[$lastPart])) {
// 重新构建命令键,去掉最后一个部分(包管理器)
return implode('.', array_slice($keyParts, 0, -1));
}
}
return $key;
}
/**
* 获取命令配置
*
* @param string $key 命令key
*
* @return array|bool
*/
public static function getCommand(string $key): bool|array
{
if (!$key) {
return false;
}
$commands = config('terminal.commands', []);
// 支持多级命令解析(如:frontend.admin.npm)
$keyParts = explode('.', $key);
$currentLevel = $commands;
foreach ($keyParts as $part) {
if (!isset($currentLevel[$part])) {
// 检查是否是安装或构建命令,尝试去掉包管理器后缀
// 注意:install.server 是后端命令,不使用包管理器
if (count($keyParts) > 2 && (in_array($keyParts[0], ['install', 'build']) && $keyParts[1] !== 'server')) {
// 重新构建命令键,去掉最后一个部分(包管理器)
$newKey = implode('.', array_slice($keyParts, 0, -1));
return self::getCommand($newKey);
}
// 对于其他命令(如debug、custom等),也尝试去掉包管理器后缀
if (count($keyParts) > 1) {
// 检查最后一个部分是否是包管理器
$lastPart = end($keyParts);
$packageManagers = config('terminal.package_managers', []);
if (isset($packageManagers[$lastPart])) {
// 重新构建命令键,去掉最后一个部分(包管理器)
$newKey = implode('.', array_slice($keyParts, 0, -1));
return self::getCommand($newKey);
}
}
// 如果是test命令,尝试使用default
if ($part === 'test' && isset($currentLevel['test']['default'])) {
$currentLevel = $currentLevel['test']['default'];
break;
}
return false;
}
$currentLevel = $currentLevel[$part];
}
// 如果最终结果不是数组,则包装为命令格式
if (!is_array($currentLevel)) {
$command = [
'cwd' => base_path(),
'command' => $currentLevel,
];
} else {
// 检查是否包含命令配置
if (isset($currentLevel['cwd']) && isset($currentLevel['command'])) {
$command = $currentLevel;
} else {
// 如果没有明确的命令配置,尝试使用default
if (isset($currentLevel['default'])) {
$command = $currentLevel['default'];
} else {
// 如果没有明确的命令配置,返回false
return false;
}
}
}
// 确保必要的字段存在
$command = array_merge([
'cwd' => base_path(),
'command' => '',
'description' => '',
'variables' => [],
'before' => null,
'after' => null,
], $command);
$command['cwd'] = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $command['cwd']);
return $command;
}
/**
* 替换命令中的变量
*
* @param string $command
* @param array $variables
*
* @return string
*/
public function replaceCommandVariables(string $command, array $variables = []): string
{
// 合并变量,确保 $this->commandVariables 的优先级高于 $variables
$allVariables = $this->commandVariables;
foreach ($variables as $key => $value) {
// 只有当 $this->commandVariables 中不存在该键时,才使用 $variables 中的值
if (!isset($allVariables[$key])) {
$allVariables[$key] = $value;
}
}
foreach ($allVariables as $variable => $value) {
// 如果值是配置键(如 'npm_package_manager'),则解析配置值
if (is_string($value) && config($value) !== null) {
$value = config($value);
}
$command = str_replace($variable, $value, $command);
}
return $command;
}
/**
* 执行命令并通过输出-Generator(HTTP请求方式)
*
* @param string|null $commandKey 命令key
* @param bool $authentication
*
* @return \Generator
* @throws \Throwable
*/
public function exec(?string $commandKey = null, bool $authentication = true): \Generator
{
$originalCommandKey = $commandKey ?? request()->input('command');
$command = self::getCommand($originalCommandKey);
if (!$command) {
yield Sse::error('命令不允许执行或不存在: ' . $originalCommandKey, null, $this->uuid);
return;
}
// 存储命令配置
$this->commandConfig = $command;
// 使用解析后的命令键(去掉包管理器后缀)
$this->commandKey = $this->getResolvedCommandKey($originalCommandKey);
// 检查服务是否启用
if (!config('terminal.enabled', false)) {
yield Sse::error(date('Y-m-d H:i',time()).' WEB终端服务未启用', null, $this->uuid);
return;
}
$this->beforeExecution();
// 执行前阶段
if (!empty($command['description'])) {
yield Sse::progress($command['description'], 0, ['stage' => 'before'], $this->uuid);
}
yield Sse::progress('连接成功', 5, ['stage' => 'connected'], $this->uuid);
// 执行前置钩子
yield from $this->executeIntercept('before', $command);
// 替换命令变量
$finalCommand = $this->replaceCommandVariables($command['command'], $command['variables'] ?? []);
// 替换 cwd 中的变量
$finalCwd = $this->replaceCommandVariables($command['cwd'], $command['variables'] ?? []);
yield Sse::progress($finalCommand, 10, ['stage' => 'before', 'command' => $finalCommand], $this->uuid);
// 执行命令
$success = $this->executeCommand($finalCommand, $finalCwd);
if (!$success) {
yield Sse::error('命令启动失败', null, $this->uuid);
return;
}
yield Sse::progress('命令开始执行', 15, ['stage' => 'executing'], $this->uuid);
// 实时输出处理
yield from $this->handleRealTimeOutput();
// 执行后处理
yield from $this->afterExecution();
}
/**
* 执行拦截器
*
* @param string $stage 阶段(before/after)
* @param array $command 命令配置
*
* @return \Generator
*/
protected function executeIntercept(string $stage, array $command): \Generator
{
// 如果配置了自定义拦截器,使用自定义拦截器
if (isset($command[$stage])) {
$hook = $command[$stage];
if (is_array($hook) && isset($hook['class']) && isset($hook['method'])) {
$class = $hook['class'];
$method = $hook['method'];
if (class_exists($class)) {
// 检查是否实现了 InterceptInterface 接口
if (in_array('app\service\core\terminal\intercept\InterceptInterface', class_implements($class))) {
if (method_exists($class, $method)) {
$instance = new $class($this->uuid);
$params = $stage === 'after' ? [$this->commandKey, $this->processExitCode ?? 0] : [];
$generator = call_user_func_array([$instance, $method], $params);
foreach ($generator as $message) {
yield $message;
}
return;
}
}
}
}
}
// 根据命令类型选择默认拦截器
$commandType = explode('.', $this->commandKey)[0] ?? 'common';
$interceptClass = self::$interceptMap[$commandType] ?? self::$interceptMap['common'];
if (class_exists($interceptClass)) {
// 检查是否实现了 InterceptInterface 接口
if (in_array('app\service\core\terminal\intercept\InterceptInterface', class_implements($interceptClass))) {
$instance = new $interceptClass($this->uuid);
$method = $stage;
if (method_exists($instance, $method)) {
$params = $stage === 'after' ? [$this->commandKey, $this->processExitCode ?? 0] : [];
$generator = call_user_func_array([$instance, $method], $params);
foreach ($generator as $message) {
yield $message;
}
}
}
}
}
/**
* 执行钩子方法
*
* @param mixed $hook 钩子配置
* @param array $params 参数
*
* @return \Generator
* @throws \Exception
*/
protected function executeHook($hook, array $params = []): \Generator
{
try {
if (is_callable($hook)) {
$result = call_user_func_array($hook, $params);
if ($result instanceof \Generator) {
yield from $result;
} elseif (is_string($result)) {
yield Sse::progress($result, 8, ['stage' => 'hook'], $this->uuid);
}
} elseif (is_array($hook) && isset($hook['class']) && isset($hook['method'])) {
$class = $hook['class'];
$method = $hook['method'];
if (class_exists($class)) {
if (method_exists($class, $method)) {
if ($class === get_class($this)) {
$result = call_user_func_array([$this, $method], $params);
} else {
$reflection = new \ReflectionClass($class);
$constructor = $reflection->getConstructor();
if ($constructor && $constructor->getNumberOfParameters() > 0) {
$instance = new $class($this->uuid);
} else {
$instance = new $class();
}
$result = call_user_func_array([$instance, $method], $params);
}
if ($result instanceof \Generator) {
yield from $result;
} elseif (is_string($result)) {
yield Sse::progress($result, 8, ['stage' => 'hook'], $this->uuid);
}
} else {
yield Sse::progress("方法 {$method} 不存在于类 {$class} 中", 8, ['stage' => 'hook_error'], $this->uuid);
}
} else {
yield Sse::progress("类 {$class} 不存在", 8, ['stage' => 'hook_error'], $this->uuid);
}
}
} catch (\Exception $e) {
yield Sse::progress("执行钩子方法时出错: " . $e->getMessage(), 8, ['stage' => 'hook_error'], $this->uuid);
}
}
/**
* 执行命令并返回简单结果(用于非SSE上下文)
*
* @param string $commandKey 命令key
* @param array $variables 命令变量
* @param string $sessionUuid 会话UUID
*
* @return array
* @throws \Throwable
*/
public function executeSimple(string $commandKey, array $variables = [], string $sessionUuid = ''): array
{
$this->commandKey = $commandKey;
$this->uuid = $sessionUuid ?: uniqid('terminal_', true);
$command = self::getCommand($this->commandKey);
if (!$command) {
return ['success' => false, 'message' => '错误:命令不允许执行或不存在: ' . $this->commandKey];
}
// 检查服务是否启用
if (!config('terminal.enabled', false)) {
return ['success' => false, 'message' => 'Terminal服务未启用'];
}
$this->beforeExecution();
// 合并命令变量
$allVariables = array_merge($command['variables'] ?? [], $variables);
$finalCommand = $this->replaceCommandVariables($command['command'], $allVariables);
// 执行命令
if (!$this->executeCommand($finalCommand, $command['cwd'])) {
return ['success' => false, 'message' => '执行失败'];
}
// 等待命令执行完成
$this->waitForCompletion();
// 获取执行结果 - 优先使用保存的状态数据中的exitcode,因为在Linux环境下proc_close可能返回-1
$exitCode = -1;
if (!empty($this->procStatusData) && isset($this->procStatusData['exitcode'])) {
$exitCode = $this->procStatusData['exitcode'];
} else if (is_resource($this->process)) {
$exitCode = proc_close($this->process);
$this->process = false;
}
// 清理资源
if (is_resource($this->process)) {
proc_close($this->process);
$this->process = false;
}
$this->pipes = [];
$this->procStatusMark = 0;
$this->procStatusData = [];
return [
'success' => $exitCode === 0,
'message' => $exitCode === 0 ? '命令执行成功' : '命令执行失败,代码: ' . $exitCode,
'exitCode' => $exitCode
];
}
/**
* 等待命令执行完成
*/
protected function waitForCompletion(): void
{
while ($this->getProcStatus()) {
usleep(100000); // 100ms
}
}
/**
* 执行命令
*
* @param string $command
* @param string $cwd
*
* @return bool
*/
public function executeCommand(string $command, string $cwd): bool
{
// 在 Windows 环境下,需要使用 cmd.exe /c 来执行命令
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
$command = 'cmd.exe /c ' . $command;
}
$this->process = proc_open($command, $this->descriptorsSpec, $this->pipes, $cwd);
return is_resource($this->process);
}
/**
* 处理实时输出
*
* @return \Generator
* @throws \Exception
*/
protected function handleRealTimeOutput(): \Generator
{
$config = config('terminal.execution', []);
$pollInterval = $config['poll_interval'] ?? 500000;
$this->outputContent = file_get_contents($this->outputFile);
$lineCount = 0;
while ($this->getProcStatus()) {
$contents = file_get_contents($this->outputFile);
if ($contents !== $this->outputContent) {
$newOutput = substr($contents, strlen($this->outputContent));
// 只要有新输出就发送事件,不管是否包含换行符
if (!empty($newOutput)) {
$lineCount++;
// 进度 15-90 之间
$progress = min(90, 15 + (int)($lineCount * 2));
yield Sse::progress($newOutput, $progress, ['stage' => 'output'], $this->uuid);
$this->outputContent = $contents;
}
}
usleep($pollInterval);
}
}
/**
* 执行后处理
*
* @return \Generator
* @throws \Exception
*/
protected function afterExecution(): \Generator
{
// 发送剩余的输出
$contents = file_get_contents($this->outputFile);
if ($contents !== $this->outputContent) {
$newOutput = substr($contents, strlen($this->outputContent));
if (!empty($newOutput)) {
yield Sse::progress($newOutput, 90, ['stage' => 'output'], $this->uuid);
}
}
// 优先使用保存的状态数据中的exitcode,因为在Linux环境下proc_close可能返回-1
$exitCode = -1;
if (!empty($this->procStatusData) && isset($this->procStatusData['exitcode'])) {
$exitCode = $this->procStatusData['exitcode'];
} else if (is_resource($this->process)) {
$exitCode = proc_close($this->process);
$this->process = false;
}
yield Sse::progress('退出代码: ' . $exitCode, 95, ['stage' => 'after', 'exitCode' => $exitCode], $this->uuid);
$success = $exitCode === 0;
// 保存退出码供拦截器使用
$this->processExitCode = $exitCode;
// 执行后置方法(无论成功失败都执行)
yield from $this->executeIntercept('after', $this->commandConfig);
if ($success) {
if ($this->successCallback()) {
yield Sse::progress('命令执行成功', 99, ['stage' => 'after'], $this->uuid);
} else {
yield Sse::error('回调执行失败', ['exitCode' => $exitCode], $this->uuid);
return;
}
}
// 清理资源
if (is_resource($this->process)) {
proc_close($this->process);
$this->process = false;
}
$this->pipes = [];
$this->procStatusMark = 0;
$this->procStatusData = [];
// 重新加载Webman(如果是特定命令)
if ($success && in_array(explode('.', $this->commandKey)[0], ['build', 'backend.composer'])) {
Util::reloadWebman();
}
// 发送完成事件
yield Sse::completed(
$success ? 'Success' : '执行失败,退出代码: ' . $exitCode,
[
'success' => $success,
'exitCode' => $exitCode,
'command' => $this->commandKey,
],
$this->uuid
);
}
/**
* 获取进程状态
*
* @return bool
*/
public function getProcStatus(): bool
{
if (!$this->process) {
return false;
}
$status = proc_get_status($this->process);
if (!$status['running']) {
$this->procStatusMark = 2;
$this->procStatusData = $status; // 保存完整的状态数据,包括exitcode
return false;
}
$this->procStatusMark = 1;
$this->procStatusData = $status;
return true;
}
/**
* 获取输出文件路径
*
* @return string
*/
public function getOutputFile(): string
{
return $this->outputFile;
}
/**
* 获取进程退出代码
*
* @return int
*/
public function getProcessExitCode(): int
{
if ($this->process) {
$status = proc_get_status($this->process);
if (isset($status['exitcode'])) {
return $status['exitcode'];
}
}
return $this->processExitCode;
}
/**
* 成功回调
*
* @return bool
*/
protected function successCallback(): bool
{
return true;
}
/**
* 执行前处理
*/
protected function beforeExecution(): void
{
// 初始化命令变量
$this->initCommandVariables();
// 初始化管道描述符
$this->initDescriptors();
// 可以在这里添加执行前的处理逻辑
}
/**
* 输出过滤器
*
* @param string $output
*
* @return string
*/
public static function outputFilter(string $output): string
{
// 过滤敏感信息或格式化输出
return $output;
}
/**
* 清理资源
*/
public function cleanupResources(): void
{
if (is_resource($this->process)) {
proc_close($this->process);
}
$this->process = false;
$this->pipes = [];
$this->procStatusMark = 0;
$this->procStatusData = [];
}
/**
* 创建新实例(工厂方法,每次请求创建新实例)
*
* @param string $uuid 会话UUID
* @param string $extend 扩展信息
* @return Terminal
*/
public static function create(string $uuid = '', string $extend = ''): Terminal
{
return new self($uuid, $extend);
}
/**
* 兼容旧代码的别名方法
* @deprecated 请使用 create() 方法
* @return Terminal
*/
public static function instance(): Terminal
{
$uuid = request()->input('uuid', '');
$extend = request()->input('extend', '');
return self::create($uuid, $extend);
}
}