510 lines
22 KiB
PHP
510 lines
22 KiB
PHP
<?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\plugin;
|
|
|
|
use app\enum\common\PluginInstallEvent;
|
|
use app\process\Monitor;
|
|
use core\exception\handler\PluginException;
|
|
use core\tool\Sse;
|
|
use core\uuid\UUIDGenerator;
|
|
use support\Container;
|
|
|
|
/**
|
|
* 安装插件服务
|
|
*/
|
|
final class PluginInstallService extends PluginBaseService
|
|
{
|
|
public function __construct()
|
|
{
|
|
parent::__construct();
|
|
}
|
|
|
|
/**
|
|
* 插件安装
|
|
*
|
|
* 安装流程统一使用插件自带的 Install.php
|
|
* PluginInstallService 只负责流程控制,实际安装逻辑完全由插件 Install.php 处理
|
|
*
|
|
* @param string $code
|
|
* @param string $mode
|
|
*
|
|
* @return \Generator
|
|
* @throws \Exception
|
|
*/
|
|
public function install(string $code, string $mode = 'local'): \Generator
|
|
{
|
|
$sessionUuid = UUIDGenerator::generate();
|
|
$request = request();
|
|
if ($request) {
|
|
$sessionUuid = $request->input('uuid', $sessionUuid);
|
|
}
|
|
|
|
// 检查插件是否已安装(检查 installed.php 文件)
|
|
$pluginDir = $this->plugin_path . DIRECTORY_SEPARATOR . $code;
|
|
$installedConfigFile = $pluginDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'installed.php';
|
|
if (is_file($installedConfigFile)) {
|
|
yield Sse::warning('插件已安装,跳过安装', ['plugin' => $code], $sessionUuid);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
yield Sse::progress(PluginInstallEvent::INSTALL_START->label(), 0, ['plugin' => $code], $sessionUuid);
|
|
|
|
// 1. 检查本地是否已经下载了模块
|
|
yield Sse::progress('检查本地模块', 5, [], $sessionUuid);
|
|
$localPluginPath = $this->plugin_path . DIRECTORY_SEPARATOR . $code;
|
|
$moduleExists = is_dir($localPluginPath);
|
|
|
|
// 2. 如果是local模式,只检测plugin目录是否包含,没有则直接返回异常安装失败
|
|
if ($mode === 'local') {
|
|
if (!$moduleExists) {
|
|
yield Sse::error('本地模块不存在,安装失败', [], $sessionUuid);
|
|
return;
|
|
}
|
|
yield Sse::progress('本地模块存在', 10, [], $sessionUuid);
|
|
} else {
|
|
// 3. 如果没有指定,优先检测本地,没有则通过远程下载模块zip
|
|
if (!$moduleExists) {
|
|
yield Sse::progress('本地模块不存在,开始远程下载', 10, [], $sessionUuid);
|
|
|
|
// 获取下载链接
|
|
$remoteService = Container::make(PluginRemoteService::class);
|
|
$authCode = config('madong.auth_code');
|
|
$secretKey = config('madong.auth_secret');
|
|
|
|
// 检查授权配置
|
|
if (empty($authCode) || empty($secretKey)) {
|
|
yield Sse::error('授权配置缺失,无法下载远程插件。请在配置文件中设置 madong.auth_code 和 madong.auth_secret', [], $sessionUuid);
|
|
return;
|
|
}
|
|
|
|
$downloadInfo = $remoteService->getRemoteDownloadService($authCode, $secretKey, $code, 'latest');
|
|
|
|
// 记录下载信息,方便调试
|
|
yield Sse::progress('下载接口返回数据: ' . json_encode($downloadInfo, JSON_UNESCAPED_UNICODE), 12, [], $sessionUuid);
|
|
|
|
if (empty($downloadInfo['url'])) {
|
|
$errorMsg = '获取下载链接失败';
|
|
if (isset($downloadInfo['msg'])) {
|
|
$errorMsg .= ': ' . $downloadInfo['msg'];
|
|
}
|
|
if (isset($downloadInfo['message'])) {
|
|
$errorMsg .= ': ' . $downloadInfo['message'];
|
|
}
|
|
yield Sse::error($errorMsg, [], $sessionUuid);
|
|
return;
|
|
}
|
|
|
|
yield Sse::progress('获取下载链接成功: ' . $downloadInfo['url'], 15, [], $sessionUuid);
|
|
|
|
// 下载并解压插件
|
|
yield Sse::progress('开始下载并解压模块', 20, [], $sessionUuid);
|
|
$downloadService = Container::make(PluginDownloadService::class);
|
|
$downloadService->downloadAndExtract($code, $downloadInfo['url']);
|
|
yield Sse::progress('模块下载并解压完成', 30, [], $sessionUuid);
|
|
} else {
|
|
yield Sse::progress('本地模块存在', 20, [], $sessionUuid);
|
|
}
|
|
}
|
|
|
|
// 5. 安装检查
|
|
yield Sse::progress(PluginInstallEvent::INSTALL_CHECK->label(), 35, [], $sessionUuid);
|
|
$checkResult = $this->installCheck($code);
|
|
if ($checkResult['overall_status'] !== 'success') {
|
|
yield Sse::error($checkResult['message'], [], $sessionUuid);
|
|
return;
|
|
}
|
|
Monitor::pause();
|
|
|
|
// 6. 统一调用插件 Install.php 进行安装(这是唯一的安装入口)
|
|
yield Sse::progress(PluginInstallEvent::EXECUTE_INSTALL_METHOD->label(), 50, [], $sessionUuid);
|
|
$pluginConfig = $this->getPluginConfig($code);
|
|
$version = $pluginConfig['version'] ?? '1.0.0';
|
|
|
|
// 检查插件是否提供 Install.php
|
|
$className = "plugin\\{$code}\\Install";
|
|
if (!class_exists($className)) {
|
|
throw new PluginException("插件未提供 Install.php,无法进行安装");
|
|
}
|
|
|
|
// 实例化并执行插件 Install 类(传入进度回调以支持在线模式反馈)
|
|
$installInstance = new $className();
|
|
|
|
// 创建临时输出文件
|
|
$outputFile = runtime_path(self::RUNTIME_PLUGIN_PATH . '/install_' . $sessionUuid . '.log');
|
|
// 确保目录存在
|
|
$outputDir = dirname($outputFile);
|
|
if (!is_dir($outputDir)) {
|
|
mkdir($outputDir, 0777, true);
|
|
}
|
|
file_put_contents($outputFile, '');
|
|
|
|
// 定义进度回调,将插件的安装进度实时写入文件
|
|
$callbackCount = 0;
|
|
$progressCallback = function(string $message, ?int $progress = null) use ($outputFile, &$callbackCount) {
|
|
$callbackCount++;
|
|
$progressStr = $progress !== null ? "(progress: {$progress})" : "(no progress)";
|
|
$logLine = "[CALLBACK #{$callbackCount}] {$message} {$progressStr}\n";
|
|
// 写入文件(追加模式),不输出到控制台避免重复
|
|
file_put_contents($outputFile, $logLine, FILE_APPEND);
|
|
};
|
|
|
|
// 设置进度回调和在线模式
|
|
$installInstance->setProgressCallback($progressCallback);
|
|
$installInstance->setOnlineMode(true);
|
|
|
|
// 开启输出缓冲,捕获 Install::install() 内部的 echo 输出
|
|
ob_start();
|
|
try {
|
|
$installInstance->install($version);
|
|
} catch (\Throwable $e) {
|
|
ob_end_clean();
|
|
@unlink($outputFile); // 清理临时文件
|
|
yield Sse::error('插件安装执行失败: ' . $e->getMessage(), [
|
|
'exception' => get_class($e),
|
|
'file' => $e->getFile(),
|
|
'line' => $e->getLine(),
|
|
], $sessionUuid);
|
|
return;
|
|
}
|
|
$output = ob_get_clean();
|
|
|
|
// 将缓冲区输出也写入临时文件
|
|
if (!empty($output)) {
|
|
file_put_contents($outputFile, $output, FILE_APPEND);
|
|
}
|
|
|
|
// 读取文件内容并 yield 所有日志
|
|
$fileContent = file_get_contents($outputFile);
|
|
if (!empty($fileContent)) {
|
|
$lines = array_filter(explode("\n", $fileContent));
|
|
foreach ($lines as $line) {
|
|
$line = trim($line);
|
|
if (!empty($line)) {
|
|
yield Sse::progress($line, 50, [], $sessionUuid);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 清理临时文件
|
|
@unlink($outputFile);
|
|
|
|
yield Sse::progress('插件安装方法执行成功', 85, [], $sessionUuid);
|
|
|
|
// 8. 创建安装标记文件
|
|
yield Sse::progress('创建安装标记', 90, [], $sessionUuid);
|
|
$installedConfig = [
|
|
'version' => $pluginConfig['version'] ?? '1.0.0',
|
|
'installed_at' => date('Y-m-d H:i:s'),
|
|
];
|
|
file_put_contents($installedConfigFile, '<?php return ' . var_export($installedConfig, true) . ';');
|
|
yield Sse::progress('安装标记创建成功', 95, [], $sessionUuid);
|
|
|
|
// 9. 安装完成
|
|
yield Sse::completed(PluginInstallEvent::INSTALL_COMPLETED->label(), ['plugin' => $code], $sessionUuid);
|
|
Monitor::pause();
|
|
|
|
} catch (PluginException $e) {
|
|
yield Sse::error($e->getMessage(), [], $sessionUuid);
|
|
return;
|
|
} catch (\Exception $e) {
|
|
yield Sse::error('安装失败:' . $e->getMessage(), [], $sessionUuid);
|
|
return;
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* 安装检查
|
|
*
|
|
* @param string $name 插件名称
|
|
*
|
|
* @return array 检查结果
|
|
*/
|
|
public function installCheck(string $name): array
|
|
{
|
|
// 初始化返回结果
|
|
$result = [
|
|
'project' => $name,
|
|
'overall_status' => 'success',
|
|
'message' => '检查通过',
|
|
'checks' => [],
|
|
];
|
|
|
|
// 定义检查项数组
|
|
$checks = [];
|
|
|
|
try {
|
|
// 1. 核心目录存在性检查(前端目录统一在 frontend 下)
|
|
$coreDirectories = [
|
|
'admin' => [
|
|
'path' => $this->getFrontendProjectPath('admin') . DIRECTORY_SEPARATOR,
|
|
'name' => 'Admin目录',
|
|
],
|
|
'web' => [
|
|
'path' => $this->getFrontendProjectPath('web') . DIRECTORY_SEPARATOR,
|
|
'name' => 'Web目录',
|
|
],
|
|
];
|
|
|
|
foreach ($coreDirectories as $dirKey => $dirInfo) {
|
|
if (is_dir($dirInfo['path'])) {
|
|
$checks[] = [
|
|
'type' => 'directory',
|
|
'name' => $dirInfo['name'],
|
|
'path' => $dirInfo['path'],
|
|
'status' => 'success',
|
|
'message' => "{$dirInfo['name']}存在",
|
|
'exception' => null,
|
|
];
|
|
} else {
|
|
$checks[] = [
|
|
'type' => 'directory',
|
|
'name' => $dirInfo['name'],
|
|
'path' => $dirInfo['path'],
|
|
'status' => 'error',
|
|
'message' => "{$dirInfo['name']}不存在",
|
|
'exception' => [
|
|
'type' => 'DirectoryNotFoundException',
|
|
'message' => __('common.plugin.error.dir_not_exist', ['name' => $dirKey]),
|
|
],
|
|
];
|
|
}
|
|
}
|
|
|
|
// 2. 插件安装目录权限检查(前端目录统一在 frontend 下)
|
|
$adminPluginDirectory = $this->getFrontendProjectPath('admin') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'apps' . DIRECTORY_SEPARATOR;
|
|
$webPluginDirectory = $this->getFrontendProjectPath('web') . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'apps' . DIRECTORY_SEPARATOR;
|
|
$resourceDirectory = public_path() . DIRECTORY_SEPARATOR;
|
|
|
|
$permissionChecks = [
|
|
[
|
|
'name' => '插件源码目录',
|
|
'path' => $this->plugin_path,
|
|
'permission_type' => 'readable',
|
|
'description' => '插件源码目录可读性检查',
|
|
],
|
|
[
|
|
'name' => 'Admin插件目录',
|
|
'path' => $adminPluginDirectory,
|
|
'permission_type' => 'writable',
|
|
'description' => '插件将安装到的Admin目录可写性检查',
|
|
],
|
|
[
|
|
'name' => 'Web插件目录',
|
|
'path' => $webPluginDirectory,
|
|
'permission_type' => 'writable',
|
|
'description' => '插件将安装到的Web目录可写性检查',
|
|
],
|
|
[
|
|
'name' => '公共资源目录',
|
|
'path' => $resourceDirectory,
|
|
'permission_type' => 'writable',
|
|
'description' => '插件资源将安装到的公共目录可写性检查',
|
|
],
|
|
];
|
|
|
|
foreach ($permissionChecks as $checkItem) {
|
|
$path = $checkItem['path'];
|
|
$checkType = $checkItem['permission_type'];
|
|
$checkName = $checkItem['name'];
|
|
|
|
if ($checkType === 'readable') {
|
|
$status = is_readable($path) ? 'success' : 'error';
|
|
$message = $status === 'success' ? "{$checkName}可读" : "{$checkName}不可读";
|
|
} else {
|
|
// 可写检查:目录存在则检查是否可写,不存在则尝试创建
|
|
if (is_dir($path)) {
|
|
$status = is_write($path) ? 'success' : 'error';
|
|
$message = $status === 'success' ? "{$checkName}可写" : "{$checkName}不可写";
|
|
} else {
|
|
$status = mkdir($path, 0777, true) ? 'success' : 'error';
|
|
$message = $status === 'success' ? "{$checkName}创建成功并可写" : "{$checkName}创建失败或不可写";
|
|
}
|
|
}
|
|
|
|
$checks[] = [
|
|
'type' => 'permission',
|
|
'name' => $checkName,
|
|
'path' => $path,
|
|
'permission_type' => $checkType,
|
|
'description' => $checkItem['description'],
|
|
'status' => $status,
|
|
'message' => $message,
|
|
'exception' => $status === 'error' ? [
|
|
'type' => 'PermissionException',
|
|
'message' => $message,
|
|
] : null,
|
|
];
|
|
}
|
|
|
|
// 4. 插件信息检查
|
|
$pluginConfig = $this->getPluginConfig($name);
|
|
if (empty($pluginConfig)) {
|
|
$checks[] = [
|
|
'type' => 'plugin_info',
|
|
'name' => '插件信息检查',
|
|
'description' => '检查插件的info.php文件是否存在',
|
|
'status' => 'error',
|
|
'message' => "插件信息文件不存在",
|
|
'exception' => [
|
|
'type' => 'InfoFileNotFoundException',
|
|
'message' => __('common.plugin.error.info_file_not_exist', ['name' => $name]),
|
|
],
|
|
];
|
|
} else {
|
|
$checks[] = [
|
|
'type' => 'plugin_info',
|
|
'name' => '插件信息检查',
|
|
'description' => '验证插件信息文件的完整性',
|
|
'status' => 'success',
|
|
'message' => "插件信息文件存在",
|
|
'plugin_info' => [
|
|
'code' => $pluginConfig['code']??'',
|
|
'name' => $pluginConfig['name']??'',
|
|
'description' => $pluginConfig['description'] ?? '',
|
|
'version' => $pluginConfig['version'],
|
|
],
|
|
'exception' => null,
|
|
];
|
|
}
|
|
|
|
|
|
|
|
// 3. 插件安装状态检查(检查 installed.php 文件)
|
|
$pluginDir = $this->plugin_path . DIRECTORY_SEPARATOR . $name;
|
|
$installedConfigFile = $pluginDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'installed.php';
|
|
|
|
if (is_file($installedConfigFile)) {
|
|
$checks[] = [
|
|
'type' => 'installation_status',
|
|
'name' => '插件安装状态检查',
|
|
'description' => '检查插件是否已安装',
|
|
'status' => 'error',
|
|
'message' => "插件已安装",
|
|
'exception' => [
|
|
'type' => 'PluginAlreadyInstalledException',
|
|
'message' => __('common.plugin.error.repeat_install', ['name' => $name]),
|
|
],
|
|
];
|
|
} else {
|
|
$checks[] = [
|
|
'type' => 'installation_status',
|
|
'name' => '插件安装状态检查',
|
|
'description' => '确认插件未安装',
|
|
'status' => 'success',
|
|
'message' => "插件未安装,可以继续",
|
|
'exception' => null,
|
|
];
|
|
}
|
|
|
|
// 4. 插件依赖检查(检查 installed.php 文件)
|
|
if (isset($pluginConfig['support_app']) && !empty($pluginConfig['support_app']) && $pluginConfig['support_app'] != $name) {
|
|
$supportAppCode = $pluginConfig['support_app'];
|
|
$supportAppDir = $this->plugin_path . DIRECTORY_SEPARATOR . $supportAppCode;
|
|
$supportAppInstalledFile = $supportAppDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'installed.php';
|
|
|
|
if (!is_file($supportAppInstalledFile)) {
|
|
$supportAppConfig = $this->getPluginConfig($supportAppCode);
|
|
$supportAppTitle = empty($supportAppConfig) ? $supportAppCode : $supportAppConfig['title'];
|
|
|
|
$checks[] = [
|
|
'type' => 'dependency',
|
|
'name' => '插件依赖检查',
|
|
'description' => '检查插件依赖的主应用是否已安装',
|
|
'dependency_name' => $supportAppCode,
|
|
'dependency_title' => $supportAppTitle,
|
|
'status' => 'error',
|
|
'message' => "依赖插件未安装",
|
|
'exception' => [
|
|
'type' => 'DependencyNotFoundException',
|
|
'message' => $pluginConfig['title'] . '插件的主应用' . $supportAppTitle . '插件还未安装,请先安装主应用',
|
|
],
|
|
];
|
|
} else {
|
|
$checks[] = [
|
|
'type' => 'dependency',
|
|
'name' => '插件依赖检查',
|
|
'description' => '确认插件依赖已满足',
|
|
'dependency_name' => $supportAppCode,
|
|
'status' => 'success',
|
|
'message' => "依赖插件已安装",
|
|
'exception' => null,
|
|
];
|
|
}
|
|
}
|
|
|
|
// 5. 框架版本支持检查
|
|
$currentFrameworkVersion = config('config.app.version', 'v1.0.1');
|
|
|
|
if (isset($pluginConfig['support_version']) && !empty($pluginConfig['support_version'])) {
|
|
$checks[] = [
|
|
'type' => 'version',
|
|
'name' => '框架版本支持检查',
|
|
'description' => '验证当前框架版本是否支持该插件',
|
|
'current_version' => $currentFrameworkVersion,
|
|
'required_version' => $pluginConfig['support_version'],
|
|
'status' => 'success',
|
|
'message' => "当前框架版本支持该插件",
|
|
'exception' => null,
|
|
];
|
|
}
|
|
|
|
} catch (\Exception $e) {
|
|
// 捕获异常
|
|
$checks[] = [
|
|
'type' => 'system',
|
|
'name' => '系统检查',
|
|
'description' => '系统级检查',
|
|
'status' => 'error',
|
|
'message' => $e->getMessage(),
|
|
'exception' => [
|
|
'type' => get_class($e),
|
|
'message' => $e->getMessage(),
|
|
'code' => $e->getCode(),
|
|
'file' => $e->getFile(),
|
|
'line' => $e->getLine(),
|
|
],
|
|
];
|
|
}
|
|
|
|
// 计算整体状态
|
|
$errorCount = 0;
|
|
$warningCount = 0;
|
|
|
|
foreach ($checks as $check) {
|
|
if ($check['status'] === 'error') {
|
|
$errorCount++;
|
|
} elseif ($check['status'] === 'warning') {
|
|
$warningCount++;
|
|
}
|
|
}
|
|
|
|
if ($errorCount > 0) {
|
|
$result['overall_status'] = 'error';
|
|
$result['message'] = "检查失败,发现{$errorCount}个错误";
|
|
} elseif ($warningCount > 0) {
|
|
$result['overall_status'] = 'warning';
|
|
$result['message'] = "检查通过但有{$warningCount}个警告";
|
|
} else {
|
|
$result['overall_status'] = 'success';
|
|
$result['message'] = "所有检查通过";
|
|
}
|
|
|
|
// 添加检查项到结果
|
|
$result['checks'] = $checks;
|
|
|
|
return $result;
|
|
}
|
|
|
|
} |