初始化本地仓库
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
<?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 core\base\BaseService;
|
||||
|
||||
/**
|
||||
* 插件基类服务
|
||||
*
|
||||
* 提供基础路径属性和插件方法执行能力
|
||||
* CLI 和在线模式共享同一套逻辑
|
||||
*/
|
||||
abstract class PluginBaseService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 插件临时目录基础路径
|
||||
*/
|
||||
const RUNTIME_PLUGIN_PATH = 'install/plugin';
|
||||
|
||||
/**
|
||||
* 获取插件构建目录
|
||||
*/
|
||||
public static function getPluginBuildPath(): string
|
||||
{
|
||||
return self::RUNTIME_PLUGIN_PATH . '/build';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件包输出目录
|
||||
*/
|
||||
public static function getPluginPackagesPath(): string
|
||||
{
|
||||
return self::RUNTIME_PLUGIN_PATH . '/packages';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件终端输出目录
|
||||
*/
|
||||
public static function getPluginTerminalPath(): string
|
||||
{
|
||||
return self::RUNTIME_PLUGIN_PATH . '/terminal';
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件根目录
|
||||
*/
|
||||
protected string $plugin_path;
|
||||
|
||||
/**
|
||||
* 项目根目录(前端和后端的父级目录)
|
||||
* 注意:此路径已不再直接指向前端目录
|
||||
* 前端目录统一在 $project_path/frontend 下,如 admin、web、uni-app 等
|
||||
*/
|
||||
protected string $project_path;
|
||||
|
||||
/**
|
||||
* 后端根目录
|
||||
*/
|
||||
protected string $server_path;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->plugin_path = base_path('plugin');
|
||||
$this->project_path = dirname(base_path()); // 项目根目录(前端和后端的父级)
|
||||
$this->server_path = base_path(); // 后端根目录
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取前端基础目录
|
||||
* 前端代码统一在 frontend 目录下
|
||||
*
|
||||
* @return string 前端基础目录
|
||||
*/
|
||||
public function getFrontendPath(): string
|
||||
{
|
||||
return $this->project_path . DIRECTORY_SEPARATOR . 'frontend';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取前端指定项目目录
|
||||
*
|
||||
* @param string $frontendType 前端类型(admin/web/uni-app)
|
||||
* @return string 前端项目目录
|
||||
*/
|
||||
public function getFrontendProjectPath(string $frontendType): string
|
||||
{
|
||||
return $this->getFrontendPath() . DIRECTORY_SEPARATOR . $frontendType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置(兼容 ConfigTrait)
|
||||
*
|
||||
* @param string $code 插件编码
|
||||
* @return array|null
|
||||
*/
|
||||
protected function getPluginConfig(string $code): ?array
|
||||
{
|
||||
$configFile = $this->plugin_path . DIRECTORY_SEPARATOR . $code . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'info.php';
|
||||
if (is_file($configFile)) {
|
||||
return include $configFile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行插件 Install 类方法
|
||||
*
|
||||
* 统一调用插件的 install/uninstall/update 方法
|
||||
* CLI 和在线模式都通过此方法调用
|
||||
*
|
||||
* @param string $code 插件编码
|
||||
* @param string $action 方法名 (install/uninstall/update)
|
||||
* @param string|null $version 版本(对于 install 是目标版本,对于 update 是新版本)
|
||||
* @param string|null $oldVersion 旧版本(仅 update 时使用)
|
||||
* @param bool $silent 是否静默执行失败(true: 失败不抛出异常, false: 失败抛出异常)
|
||||
* @return bool 是否成功执行
|
||||
* @throws \Exception 当 silent=false 且执行失败时抛出异常
|
||||
*/
|
||||
protected function executePluginMethod(string $code, string $action, ?string $version = null, ?string $oldVersion = null, bool $silent = false): bool
|
||||
{
|
||||
$className = "plugin\\" . $code . "\\Install";
|
||||
if (!class_exists($className)) {
|
||||
// 插件未提供 Install 类,静默跳过
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$pluginConfig = $this->getPluginConfig($code);
|
||||
$version = $pluginConfig['version'] ?? ($version ?? '1.0.0');
|
||||
|
||||
// 实例化插件 Install 类
|
||||
$installInstance = new $className();
|
||||
|
||||
switch ($action) {
|
||||
case 'install':
|
||||
if (method_exists($installInstance, 'install')) {
|
||||
$installInstance->install($version);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'uninstall':
|
||||
if (method_exists($installInstance, 'uninstall')) {
|
||||
$installInstance->uninstall($version);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
if (method_exists($installInstance, 'update')) {
|
||||
$installInstance->update($oldVersion ?? '1.0.0', $version);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
// 插件方法执行失败,记录日志
|
||||
$logLevel = $silent ? 'warning' : 'error';
|
||||
\support\Log::$logLevel('插件方法执行失败', [
|
||||
'plugin' => $code,
|
||||
'action' => $action,
|
||||
'version' => $version,
|
||||
'old_version' => $oldVersion,
|
||||
'exception' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
|
||||
// 静默模式不抛出异常,非静默模式抛出异常
|
||||
if (!$silent) {
|
||||
throw $e;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用插件 Install 类的方法(兼容 CLI 模式)
|
||||
*
|
||||
* 此方法专门用于 CLI 命令直接调用插件方法
|
||||
* 与在线模式的区别:CLI 模式不需要 SSE 返回,直接执行
|
||||
*
|
||||
* @param string $code 插件编码
|
||||
* @param string $action 方法名 (install/uninstall/update)
|
||||
* @param string|null $version 版本
|
||||
* @param string|null $oldVersion 旧版本(仅 update 时使用)
|
||||
* @return bool 是否成功
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function callPluginInstallMethod(string $code, string $action, ?string $version = null, ?string $oldVersion = null): bool
|
||||
{
|
||||
return $this->executePluginMethod($code, $action, $version, $oldVersion, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
<?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\service\admin\system\MenuService;
|
||||
use core\exception\handler\PluginException;
|
||||
|
||||
/**
|
||||
* 插件构建服务
|
||||
*/
|
||||
final class PluginBuildService extends PluginBaseService
|
||||
{
|
||||
private ?string $plugin;
|
||||
private ?string $pluginPath;
|
||||
|
||||
/**
|
||||
* 插件打包
|
||||
*
|
||||
* @param string $plugin 插件名称
|
||||
*
|
||||
* @return true
|
||||
* @throws PluginException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function build(string $plugin): true
|
||||
{
|
||||
$this->plugin = $plugin;
|
||||
$this->pluginPath = base_path() . 'plugin' . DIRECTORY_SEPARATOR . $plugin . DIRECTORY_SEPARATOR;
|
||||
|
||||
if (!is_dir($this->pluginPath)) {
|
||||
throw new PluginException('目录中不存在此项插件');
|
||||
}
|
||||
|
||||
// 执行各模块打包
|
||||
$this->packageAdmin();
|
||||
$this->packageUniapp();
|
||||
$this->buildUniappPagesJson();
|
||||
$this->buildUniappLangJson();
|
||||
$this->packageWeb();
|
||||
$this->packageResource();
|
||||
$this->syncMenu('admin');
|
||||
$this->syncMenu('site');
|
||||
|
||||
// 清理临时文件并准备打包
|
||||
$this->cleanupAndPreparePackage();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步菜单
|
||||
*
|
||||
* @param string $appType 应用类型
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function syncMenu(string $appType): true
|
||||
{
|
||||
|
||||
$menuService = new MenuService();
|
||||
$where = [
|
||||
['app_type', '=', $appType],
|
||||
['plugin', '=', $this->plugin]
|
||||
];
|
||||
$fields = [
|
||||
'menu_name', 'menu_key', 'menu_short_name', 'parent_select_key',
|
||||
'parent_key', 'menu_type', 'icon', 'api_url', 'router_path',
|
||||
'view_path', 'methods', 'sort', 'status', 'is_show'
|
||||
];
|
||||
|
||||
$menus = $menuService->getModel()
|
||||
->query()
|
||||
->where($where)
|
||||
->order('sort', 'desc')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$menuTree = [];
|
||||
if (!empty($menus)) {
|
||||
$menuTree = $this->buildMenuTree($menus);
|
||||
$menuService->getModel()->query()->where($where)->update(['source' => 'system']);
|
||||
}
|
||||
|
||||
$menuConfigPath = $this->pluginPath . 'resource' . DIRECTORY_SEPARATOR . 'menu' . DIRECTORY_SEPARATOR . 'menu' . DIRECTORY_SEPARATOR . $appType . '.php';
|
||||
|
||||
$deleteMenuKeys = [];
|
||||
if (file_exists($menuConfigPath)) {
|
||||
$menuConfig = include $menuConfigPath;
|
||||
$deleteMenuKeys = $menuConfig['delete'] ?? [];
|
||||
}
|
||||
|
||||
$content = $this->generateMenuConfigContent($menuTree, $deleteMenuKeys);
|
||||
file_put_contents($menuConfigPath, $content);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建菜单树
|
||||
*
|
||||
* @param array $menuList 菜单列表
|
||||
* @param string $pk 主键字段
|
||||
* @param string $pid 父级ID字段
|
||||
* @param string $child 子节点字段
|
||||
* @param string $root 根节点值
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function buildMenuTree(array $menuList, string $pk = 'menu_key', string $pid = 'parent_key', string $child = 'children', string $root = ''): array
|
||||
{
|
||||
$tree = [];
|
||||
if (empty($menuList)) {
|
||||
return $tree;
|
||||
}
|
||||
|
||||
// 创建基于主键的数组引用
|
||||
$menuRefer = [];
|
||||
foreach ($menuList as $key => &$menu) {
|
||||
$menuRefer[$menu[$pk]] = &$menuList[$key];
|
||||
}
|
||||
|
||||
foreach ($menuList as $key => &$menu) {
|
||||
$parentId = $menu[$pid];
|
||||
if ($root === $parentId) {
|
||||
$tree[] = &$menuList[$key];
|
||||
} else {
|
||||
if (isset($menuRefer[$parentId])) {
|
||||
$parentMenu = &$menuRefer[$parentId];
|
||||
$parentMenu[$child][] = &$menuList[$key];
|
||||
} else {
|
||||
$tree[] = &$menuList[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成菜单配置内容
|
||||
*
|
||||
* @param array $menuTree 菜单树
|
||||
* @param array $deleteMenuKeys 要删除的菜单键
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function generateMenuConfigContent(array $menuTree, array $deleteMenuKeys): string
|
||||
{
|
||||
$content = '<?php' . PHP_EOL;
|
||||
$content .= 'return [' . PHP_EOL;
|
||||
$content .= $this->formatArrayToPhp($menuTree);
|
||||
|
||||
if (!empty($deleteMenuKeys)) {
|
||||
$deleteKeys = array_map(function ($key) {
|
||||
return "'{$key}'";
|
||||
}, $deleteMenuKeys);
|
||||
$content .= " 'delete' => [" . implode(',', $deleteKeys) . "]" . PHP_EOL;
|
||||
}
|
||||
|
||||
$content .= '];';
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数组为PHP代码字符串
|
||||
*
|
||||
* @param array $array 要格式化的数组
|
||||
* @param int $level 缩进级别
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function formatArrayToPhp(array $array, int $level = 1): string
|
||||
{
|
||||
$indent = str_repeat(' ', $level);
|
||||
$content = '';
|
||||
|
||||
foreach ($array as $key => $value) {
|
||||
// 跳过不需要的字段
|
||||
if (in_array($key, ['status_name', 'menu_type_name']) || ($level > 2 && $key == 'parent_key')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content .= $indent;
|
||||
if (is_string($key)) {
|
||||
$content .= "'{$key}' => ";
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$content .= '[' . PHP_EOL . $this->formatArrayToPhp($value, $level + 1);
|
||||
$content .= $indent . '],' . PHP_EOL;
|
||||
} else {
|
||||
$content .= "'{$value}'," . PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包Admin模块
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function packageAdmin(): true
|
||||
{
|
||||
$sourcePath = $this->getFrontendProjectPath('admin') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . $this->plugin . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($sourcePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$targetPath = $this->pluginPath . 'admin' . DIRECTORY_SEPARATOR;
|
||||
$this->copyDirectoryWithCleanup($sourcePath, $targetPath);
|
||||
|
||||
// 打包admin icon文件
|
||||
$iconSourcePath = $this->getFrontendProjectPath('admin') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'styles' . DIRECTORY_SEPARATOR . 'icon' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . $this->plugin;
|
||||
$iconTargetPath = $targetPath . 'icon' . DIRECTORY_SEPARATOR;
|
||||
|
||||
if (is_dir($iconSourcePath)) {
|
||||
$this->copyDirectoryWithCleanup($iconSourcePath, $iconTargetPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包Uniapp模块
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function packageUniapp(): true
|
||||
{
|
||||
$sourcePath = $this->getFrontendProjectPath('uni-app') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . $this->plugin . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($sourcePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$targetPath = $this->pluginPath . 'uni-app' . DIRECTORY_SEPARATOR;
|
||||
$this->copyDirectoryWithCleanup($sourcePath, $targetPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建uni-app页面配置
|
||||
*
|
||||
* @return true
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function buildUniappPagesJson(): true
|
||||
{
|
||||
$pagesJsonPath = $this->getFrontendProjectPath('uni-app') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'pages.json';
|
||||
if (!file_exists($pagesJsonPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$pagesJsonContent = file_get_contents($pagesJsonPath);
|
||||
$codeBegin = strtoupper($this->plugin) . '_PAGE_BEGIN' . PHP_EOL;
|
||||
$codeEnd = strtoupper($this->plugin) . '_PAGE_END' . PHP_EOL;
|
||||
|
||||
if (!str_contains($pagesJsonContent, $codeBegin) || !str_contains($pagesJsonContent, $codeEnd)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$pattern = "/\\/\\/\s+{$codeBegin}([\\S\\s]+)\\/\\/\s+{$codeEnd}?/";
|
||||
preg_match($pattern, $pagesJsonContent, $matches);
|
||||
|
||||
if (empty($matches)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$addonPages = str_replace(PHP_EOL . ',' . PHP_EOL, '', $matches[1]);
|
||||
$packagePath = $this->pluginPath . 'package';
|
||||
|
||||
if (!is_dir($packagePath)) {
|
||||
create_directory($packagePath);
|
||||
}
|
||||
|
||||
$content = '<?php' . PHP_EOL;
|
||||
$content .= 'return [' . PHP_EOL . " 'pages' => <<<EOT" . PHP_EOL . ' // PAGE_BEGIN' . PHP_EOL;
|
||||
$content .= $addonPages;
|
||||
$content .= '// PAGE_END' . PHP_EOL . 'EOT' . PHP_EOL . '];';
|
||||
|
||||
file_put_contents($packagePath . DIRECTORY_SEPARATOR . 'uni-app-pages.php', $content);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建uni-app语言文件
|
||||
*
|
||||
* @return true
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function buildUniappLangJson(): true
|
||||
{
|
||||
$zhJsonPath = $this->getFrontendProjectPath('uni-app') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'locale' . DIRECTORY_SEPARATOR . 'zh-Hans.json';
|
||||
$enJsonPath = $this->getFrontendProjectPath('uni-app') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'locale' . DIRECTORY_SEPARATOR . 'en.json';
|
||||
|
||||
if (!file_exists($zhJsonPath) || !file_exists($enJsonPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$zhJson = json_decode(file_get_contents($zhJsonPath), true);
|
||||
$enJson = json_decode(file_get_contents($enJsonPath), true);
|
||||
|
||||
$pluginZh = [];
|
||||
$pluginEn = [];
|
||||
$pluginKeyPrefix = $this->plugin . '.';
|
||||
|
||||
foreach ($zhJson as $key => $value) {
|
||||
if (str_starts_with($key, $pluginKeyPrefix)) {
|
||||
$newKey = str_replace($pluginKeyPrefix, '', $key);
|
||||
$pluginZh[$newKey] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($enJson as $key => $value) {
|
||||
if (str_starts_with($key, $pluginKeyPrefix)) {
|
||||
$newKey = str_replace($pluginKeyPrefix, '', $key);
|
||||
$pluginEn[$newKey] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$langDir = $this->pluginPath . 'uni-app' . DIRECTORY_SEPARATOR . 'locale' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($langDir)) {
|
||||
create_directory($langDir);
|
||||
}
|
||||
|
||||
$this->writeJsonFile($pluginZh, $langDir . 'zh-Hans.json');
|
||||
$this->writeJsonFile($pluginEn, $langDir . 'en.json');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包Web模块
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function packageWeb(): true
|
||||
{
|
||||
$sourcePath = $this->getFrontendProjectPath('web') . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . $this->plugin . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($sourcePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$targetPath = $this->pluginPath . 'web' . DIRECTORY_SEPARATOR;
|
||||
$this->copyDirectoryWithCleanup($sourcePath, $targetPath);
|
||||
|
||||
// 处理布局文件
|
||||
$layoutSourcePath = $this->getFrontendProjectPath('web') . DIRECTORY_SEPARATOR . 'layouts' . DIRECTORY_SEPARATOR . $this->plugin;
|
||||
if (is_dir($layoutSourcePath)) {
|
||||
$layoutTargetPath = $targetPath . 'layouts' . DIRECTORY_SEPARATOR . $this->plugin;
|
||||
$this->copyDirectoryWithCleanup($layoutSourcePath, $layoutTargetPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包资源文件
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function packageResource(): true
|
||||
{
|
||||
$sourcePath = public_path() . 'plugin' . DIRECTORY_SEPARATOR . $this->plugin . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($sourcePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$targetPath = $this->pluginPath . 'resource' . DIRECTORY_SEPARATOR;
|
||||
$this->copyDirectoryWithCleanup($sourcePath, $targetPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理临时文件并准备打包
|
||||
*
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function cleanupAndPreparePackage(): void
|
||||
{
|
||||
$runtimePath = runtime_path() . $this->plugin . DIRECTORY_SEPARATOR;
|
||||
$pluginRuntimePath = $runtimePath . $this->plugin;
|
||||
|
||||
// 先拷贝
|
||||
copy_directory($this->pluginPath, $pluginRuntimePath);
|
||||
|
||||
// 清理和准备ZIP文件
|
||||
$zipFile = runtime_path() . $this->plugin . '.zip';
|
||||
if (file_exists($zipFile)) {
|
||||
unlink($zipFile);
|
||||
}
|
||||
|
||||
// 清理临时目录
|
||||
remove_directory($runtimePath, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制目录并清理目标目录
|
||||
*
|
||||
* @param string $sourcePath 源目录路径
|
||||
* @param string $targetPath 目标目录路径
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function copyDirectoryWithCleanup(string $sourcePath, string $targetPath): void
|
||||
{
|
||||
if (is_dir($targetPath)) {
|
||||
remove_directory($targetPath, true);
|
||||
}
|
||||
copy_directory($sourcePath, $targetPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
<?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\plugin\FrontendType;
|
||||
|
||||
/**
|
||||
* Plugin开发服务层
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class PluginDevelopService extends PluginBaseService
|
||||
{
|
||||
/**
|
||||
* 模板文件路径映射
|
||||
*/
|
||||
private const STUB_PATHS = [
|
||||
'api' => '/stubs/admin/api/index.stub',
|
||||
'view' => '/stubs/admin/views/index.stub',
|
||||
'routes' => '/stubs/admin/routes/index.stub',
|
||||
'config' => '/stubs/server/config.stub',
|
||||
'translation' => '/stubs/server/translation.stub',
|
||||
'controller' => '/stubs/server/controller.stub',
|
||||
'install' => '/stubs/server/install.stub',
|
||||
'config_info' => '/stubs/server/config-info.stub',
|
||||
'route' => '/stubs/server/route.stub',
|
||||
'menu_admin' => '/stubs/server/menu-admin.stub',
|
||||
'menu_frontend' => '/stubs/server/menu-frontend.stub',
|
||||
'menu_web' => '/stubs/server/menu-web.stub',
|
||||
];
|
||||
|
||||
/**
|
||||
* 生成插件模板
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
* @param string $pluginDescription 插件描述
|
||||
* @param string $frontendType 前端类型,默认为 admin
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function generatePluginTemplate(string $pluginName, string $pluginTitle, string $pluginDescription = '', string $frontendType = 'admin'): array
|
||||
{
|
||||
try {
|
||||
// 生成前端目录结构
|
||||
$this->generateFrontendStructure($pluginName, $frontendType);
|
||||
|
||||
// 生成后端目录结构
|
||||
$this->generateBackendStructure($pluginName);
|
||||
|
||||
// 生成前端文件
|
||||
$this->generateFrontendFiles($pluginName, $pluginTitle, $pluginDescription, $frontendType);
|
||||
|
||||
// 生成后端文件
|
||||
$this->generateBackendFiles($pluginName, $pluginTitle, $pluginDescription);
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'message' => '插件模板生成成功',
|
||||
'data' => [
|
||||
'frontend_path' => $this->getPluginFrontendPath($pluginName, $frontendType),
|
||||
'backend_path' => $this->getBackendPath($pluginName),
|
||||
],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'code' => 500,
|
||||
'message' => '插件模板生成失败: ' . $e->getMessage(),
|
||||
'data' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前端目录结构
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $frontendType 前端类型
|
||||
*/
|
||||
private function generateFrontendStructure(string $pluginName, string $frontendType): void
|
||||
{
|
||||
$frontendPath = $this->getPluginFrontendPath($pluginName, $frontendType);
|
||||
$pluginKey = $this->toKebabCase($pluginName);
|
||||
|
||||
// 创建前端目录结构(参考 official 模块)
|
||||
$directories = [
|
||||
$frontendPath . '/api',
|
||||
$frontendPath . '/lang/zh-cn',
|
||||
$frontendPath . '/lang/en',
|
||||
$frontendPath . '/views/' . $pluginKey . '/index',
|
||||
$frontendPath . '/components',
|
||||
$frontendPath . '/routes',
|
||||
];
|
||||
|
||||
$this->createDirectories($directories);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成后端目录结构
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
*/
|
||||
private function generateBackendStructure(string $pluginName): void
|
||||
{
|
||||
$backendPath = $this->getBackendPath($pluginName);
|
||||
|
||||
// 创建后端目录结构(参考 official 模块)
|
||||
$directories = [
|
||||
$backendPath . '/config',
|
||||
$backendPath . '/app/adminapi/controller',
|
||||
$backendPath . '/app/api/controller',
|
||||
$backendPath . '/app/service/admin',
|
||||
$backendPath . '/app/service/api',
|
||||
$backendPath . '/app/validate',
|
||||
$backendPath . '/app/schema/request',
|
||||
$backendPath . '/app/schema/response',
|
||||
$backendPath . '/app/dao',
|
||||
$backendPath . '/app/model',
|
||||
$backendPath . '/public',
|
||||
// Resource 目录
|
||||
$backendPath . '/resource/database/migrations',
|
||||
$backendPath . '/resource/database/seeds',
|
||||
$backendPath . '/resource/menu',
|
||||
$backendPath . '/resource/template/admin',
|
||||
$backendPath . '/resource/template/web',
|
||||
];
|
||||
|
||||
$this->createDirectories($directories);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建目录
|
||||
*
|
||||
* @param array $directories 目录路径数组
|
||||
*/
|
||||
private function createDirectories(array $directories): void
|
||||
{
|
||||
foreach ($directories as $directory) {
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前端文件
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
* @param string $pluginDescription 插件描述
|
||||
* @param string $frontendType 前端类型
|
||||
*/
|
||||
private function generateFrontendFiles(string $pluginName, string $pluginTitle, string $pluginDescription, string $frontendType): void
|
||||
{
|
||||
$frontendPath = $this->getPluginFrontendPath($pluginName, $frontendType);
|
||||
$camelPluginName = $this->toCamelCase($pluginName);
|
||||
$pluginKey = $this->toKebabCase($pluginName);
|
||||
|
||||
// 生成 API 文件
|
||||
$this->generateApiFile($frontendPath, $pluginName, $pluginKey);
|
||||
|
||||
// 生成语言文件(中英文)
|
||||
$this->generateLangFiles($frontendPath, $pluginName, $pluginTitle, $pluginDescription);
|
||||
|
||||
// 生成页面文件(index.vue)
|
||||
$this->generatePageFile($frontendPath, $pluginName, $camelPluginName, $pluginKey);
|
||||
|
||||
// 生成 routes/index.ts 文件
|
||||
$this->generateRoutesIndexFile($frontendPath, $pluginName, $camelPluginName, $pluginTitle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成后端文件
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
* @param string $pluginDescription 插件描述
|
||||
*/
|
||||
private function generateBackendFiles(string $pluginName, string $pluginTitle, string $pluginDescription): void
|
||||
{
|
||||
$backendPath = $this->getBackendPath($pluginName);
|
||||
$camelPluginName = $this->toCamelCase($pluginName);
|
||||
$pluginKey = $this->toKebabCase($pluginName);
|
||||
|
||||
// 生成配置文件
|
||||
$this->generateConfigFiles($backendPath, $pluginName, $pluginTitle, $pluginDescription);
|
||||
|
||||
// 生成 Install.php 文件
|
||||
$this->generateInstallFile($backendPath, $pluginName, $camelPluginName);
|
||||
|
||||
// 生成路由文件(Swagger 注解方式)- 在配置文件生成之后
|
||||
$this->generateRouteFile($backendPath, $pluginName, $camelPluginName, $pluginTitle);
|
||||
|
||||
// 生成控制器文件
|
||||
$this->generateControllerFile($backendPath, $pluginName, $camelPluginName, $pluginKey);
|
||||
|
||||
// 生成菜单配置文件
|
||||
$this->generateMenuFiles($backendPath, $pluginName, $pluginTitle);
|
||||
|
||||
// 生成前端模板到 resource/template/admin 目录
|
||||
$this->generateFrontendTemplates($backendPath, $pluginName, $camelPluginName, $pluginKey, $pluginTitle);
|
||||
|
||||
// 初始化公共资源文件
|
||||
$this->initializePublicAssets($backendPath);
|
||||
|
||||
// 为其他目录创建 .gitkeep 文件
|
||||
$this->createGitKeepFiles($backendPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化公共资源文件
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
*/
|
||||
private function initializePublicAssets(string $backendPath): void
|
||||
{
|
||||
$publicPath = $backendPath . '/public';
|
||||
|
||||
// 创建 public 目录(如果不存在)
|
||||
if (!is_dir($publicPath)) {
|
||||
mkdir($publicPath, 0755, true);
|
||||
}
|
||||
|
||||
// 创建默认的 icon.png 文件(简单透明PNG)
|
||||
$iconPath = $publicPath . '/icon.png';
|
||||
if (!file_exists($iconPath)) {
|
||||
$transparentPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH5AgHFA0yMklDQgAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAvElEQVRYw+2W0QqDMBBEbwQv/r/vW7HgVSh4EcSi3mw2SQm0ONjZzQfOwsLMZDLZZjAYuNfr1f6ZJCImIpqmeX+1WnUDuL8AACYFQRCgqqo+AFRV1d+dw+FoAQAiQlEUPQB4PB5YLpcAgMViAcMwIIoiHo/HXwGA4jgcDiiKYgiAuq6RZRmyLOsBoGkaEZHf9/0AAJqmQRRFaNsWvu/Dtm0wxkBKS0sp6bquCyJaEkXRLwCqqkIURei6DkVRQCl1CYBhGFBVFYqiAKX0JgBSSnRdh6IoEEURGGPKA4BlWZBSou/7SwCklEiSBEmSgDHmNgBSSnRdh6IoYIzpAWCMQRQJaa27AUAIIYQQQgghhBBCCCHf8wL0taA/yDgfmQAAAABJRU5ErkJggg==');
|
||||
file_put_contents($iconPath, $transparentPng);
|
||||
}
|
||||
|
||||
// 创建默认的 cover.png 文件(简单蓝色PNG)
|
||||
$coverPath = $publicPath . '/cover.png';
|
||||
if (!file_exists($coverPath)) {
|
||||
$bluePng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH5AgHFA4ZEwgJOwAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAyUlEQVRYw+2WsQrCQBCGP1+hFhYWaS0sLCz8gIWFhYWFrQVBsLQQFEQLCwuxsBAEwUISBEELC0EQBMHvJGcnF8gFNzezm82+ZTYzM8nswvx+xJjFGDPHcYwxxlLK+LZKKSGEGMcx5pzjnBs3ACmlEEKMc05rbTcAUsrY7/cwxmCMQWttFwCtNVhr0VrDGNMNgNYarTWMMTDGYIyxCwBrLUKIUErBGINSCq01WmtQSsE5hzEGay2cc2OMMcYY/xZAKQXn3BiGAADGGDjnMMaglILWGkopOOcwxsAYgzEGYwycc3DOjf8CQAhhrLVwzuGcQ2uN1hqcc3DOYYxBKYXWWjjn4JyDc278VgDGGLTWcM6NzjmstXDODc45OOcwxiCEgNYarTU45+Ccg3Nu/BYAay2cc3DOIYSAtRbOOYwxCCGgtYYxBmMMnHMwxmCthXMOzjk45+Ccg/M/AKy1cM7BOYcQAloLnHNwzsE5ByEEtNZgjMFaC2MMnHMwxmCtgXMOzjk45+Ccg/M/AKy1cM7BOYcQAloLnHNwzsE5ByEEtNZgjMFaC+ccnHNwzsE5Nzjn4JyDcw7OuTHG+DMAAP//AwDclW/p/qcTdAAAAABJRU5ErkJggg==');
|
||||
file_put_contents($coverPath, $bluePng);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 API 文件
|
||||
*
|
||||
* @param string $frontendPath 前端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginKey 插件键名
|
||||
*/
|
||||
private function generateApiFile(string $frontendPath, string $pluginName, string $pluginKey): void
|
||||
{
|
||||
$stubPath = __DIR__ . self::STUB_PATHS['api'];
|
||||
$content = $this->renderStub($stubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
]);
|
||||
file_put_contents($frontendPath . '/api/index.ts', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成语言文件(中英文)
|
||||
*
|
||||
* @param string $frontendPath 前端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
* @param string $pluginDescription 插件描述
|
||||
*/
|
||||
private function generateLangFiles(string $frontendPath, string $pluginName, string $pluginTitle, string $pluginDescription): void
|
||||
{
|
||||
// 中文语言包
|
||||
$zhContent = json_encode([
|
||||
$pluginName => [
|
||||
'title' => $pluginTitle,
|
||||
'description' => $pluginDescription,
|
||||
'card_title' => $pluginTitle . '数据',
|
||||
'refresh_btn' => '刷新',
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
file_put_contents($frontendPath . '/lang/zh-cn/zh.json', $zhContent . PHP_EOL);
|
||||
|
||||
// 英文语言包
|
||||
$enContent = json_encode([
|
||||
$pluginName => [
|
||||
'title' => $pluginTitle,
|
||||
'description' => $pluginDescription,
|
||||
'card_title' => $pluginTitle . ' Data',
|
||||
'refresh_btn' => 'Refresh',
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
file_put_contents($frontendPath . '/lang/en/en.json', $enContent . PHP_EOL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成页面文件
|
||||
*
|
||||
* @param string $frontendPath 前端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $camelPluginName 驼峰命名的插件名称
|
||||
* @param string $pluginKey 插件键名
|
||||
*/
|
||||
private function generatePageFile(string $frontendPath, string $pluginName, string $camelPluginName, string $pluginKey): void
|
||||
{
|
||||
$stubPath = __DIR__ . self::STUB_PATHS['view'];
|
||||
$content = $this->renderStub($stubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
]);
|
||||
file_put_contents($frontendPath . '/views/' . $pluginKey . '/index/index.vue', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成路由文件
|
||||
*
|
||||
* @param string $frontendPath 前端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $camelPluginName 驼峰命名的插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
*/
|
||||
private function generateRoutesIndexFile(string $frontendPath, string $pluginName, string $camelPluginName, string $pluginTitle): void
|
||||
{
|
||||
$stubPath = __DIR__ . self::STUB_PATHS['routes'];
|
||||
$content = $this->renderStub($stubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
'{{pluginKey}}' => $this->toKebabCase($pluginName),
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
]);
|
||||
file_put_contents($frontendPath . '/routes/index.ts', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成配置文件
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
* @param string $pluginDescription 插件描述
|
||||
*/
|
||||
private function generateConfigFiles(string $backendPath, string $pluginName, string $pluginTitle, string $pluginDescription): void
|
||||
{
|
||||
// app.php 配置
|
||||
$appStubPath = __DIR__ . self::STUB_PATHS['config'];
|
||||
$appContent = $this->renderStub($appStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
'{{pluginDescription}}' => $pluginDescription,
|
||||
]);
|
||||
file_put_contents($backendPath . '/config/app.php', $appContent);
|
||||
|
||||
// translation.php 配置
|
||||
$translationStubPath = __DIR__ . self::STUB_PATHS['translation'];
|
||||
$translationContent = $this->renderStub($translationStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
]);
|
||||
file_put_contents($backendPath . '/config/translation.php', $translationContent);
|
||||
|
||||
// info.php 配置
|
||||
$infoStubPath = __DIR__ . self::STUB_PATHS['config_info'];
|
||||
$infoContent = $this->renderStub($infoStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
'{{pluginDescription}}' => $pluginDescription,
|
||||
]);
|
||||
file_put_contents($backendPath . '/config/info.php', $infoContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Install.php 文件
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $camelPluginName 驼峰命名的插件名称
|
||||
*/
|
||||
private function generateInstallFile(string $backendPath, string $pluginName, string $camelPluginName): void
|
||||
{
|
||||
$stubPath = __DIR__ . self::STUB_PATHS['install'];
|
||||
$content = $this->renderStub($stubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
]);
|
||||
file_put_contents($backendPath . '/Install.php', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成路由文件(Swagger 注解方式)
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $camelPluginName 驼峰命名的插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
*/
|
||||
private function generateRouteFile(string $backendPath, string $pluginName, string $camelPluginName, string $pluginTitle): void
|
||||
{
|
||||
$stubPath = __DIR__ . self::STUB_PATHS['route'];
|
||||
$pluginKey = $this->toKebabCase($pluginName);
|
||||
$content = $this->renderStub($stubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
]);
|
||||
file_put_contents($backendPath . '/config/route.php', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成控制器文件
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $camelPluginName 驼峰命名的插件名称
|
||||
* @param string $pluginKey 插件键名
|
||||
*/
|
||||
private function generateControllerFile(string $backendPath, string $pluginName, string $camelPluginName, string $pluginKey): void
|
||||
{
|
||||
$stubPath = __DIR__ . self::STUB_PATHS['controller'];
|
||||
$content = $this->renderStub($stubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
]);
|
||||
file_put_contents($backendPath . '/app/adminapi/controller/' . $camelPluginName . 'Controller.php', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为目录创建 .gitkeep 文件
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
*/
|
||||
private function createGitKeepFiles(string $backendPath): void
|
||||
{
|
||||
$directories = [
|
||||
'/app/schema/request',
|
||||
'/app/schema/response',
|
||||
'/app/dao',
|
||||
'/app/model',
|
||||
'/app/service/admin',
|
||||
'/app/service/api',
|
||||
'/app/validate',
|
||||
'/app/api/controller',
|
||||
'/resource/database/migrations',
|
||||
'/resource/database/seeds',
|
||||
'/resource/template/admin',
|
||||
'/resource/template/web',
|
||||
];
|
||||
|
||||
foreach ($directories as $path) {
|
||||
$directory = $backendPath . $path;
|
||||
if (is_dir($directory)) {
|
||||
file_put_contents($directory . '/.gitkeep', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成菜单配置文件
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $pluginTitle 插件标题
|
||||
*/
|
||||
private function generateMenuFiles(string $backendPath, string $pluginName, string $pluginTitle): void
|
||||
{
|
||||
$pluginKey = $this->toKebabCase($pluginName);
|
||||
|
||||
// 生成 admin 菜单
|
||||
$adminStubPath = __DIR__ . self::STUB_PATHS['menu_admin'];
|
||||
$adminContent = $this->renderStub($adminStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
]);
|
||||
file_put_contents($backendPath . '/resource/menu/admin.php', $adminContent);
|
||||
|
||||
// 生成 frontend 菜单
|
||||
$frontendStubPath = __DIR__ . self::STUB_PATHS['menu_frontend'];
|
||||
$frontendContent = $this->renderStub($frontendStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
]);
|
||||
file_put_contents($backendPath . '/resource/menu/frontend.php', $frontendContent);
|
||||
|
||||
// 生成 web 菜单
|
||||
$webStubPath = __DIR__ . self::STUB_PATHS['menu_web'];
|
||||
$webContent = $this->renderStub($webStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
]);
|
||||
file_put_contents($backendPath . '/resource/menu/web.php', $webContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前端模板到 resource/template/admin 目录
|
||||
*
|
||||
* @param string $backendPath 后端路径
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $camelPluginName 驼峰命名的插件名称
|
||||
* @param string $pluginKey 插件键名
|
||||
* @param string $pluginTitle 插件标题
|
||||
*/
|
||||
private function generateFrontendTemplates(string $backendPath, string $pluginName, string $camelPluginName, string $pluginKey, string $pluginTitle): void
|
||||
{
|
||||
$templatePath = $backendPath . '/resource/template/admin';
|
||||
|
||||
// 创建模板目录结构
|
||||
$templateDirectories = [
|
||||
$templatePath . '/api',
|
||||
$templatePath . '/lang/zh-cn',
|
||||
$templatePath . '/lang/en',
|
||||
$templatePath . '/views/' . $pluginKey . '/index',
|
||||
$templatePath . '/components',
|
||||
$templatePath . '/routes',
|
||||
];
|
||||
|
||||
foreach ($templateDirectories as $directory) {
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成 API 文件
|
||||
$apiStubPath = __DIR__ . self::STUB_PATHS['api'];
|
||||
$apiContent = $this->renderStub($apiStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
]);
|
||||
file_put_contents($templatePath . '/api/index.ts', $apiContent);
|
||||
|
||||
// 生成语言文件
|
||||
$zhContent = json_encode([
|
||||
$pluginName => [
|
||||
'title' => $pluginTitle,
|
||||
'description' => '插件示例页面',
|
||||
'card_title' => $pluginTitle . '数据',
|
||||
'refresh_btn' => '刷新',
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
file_put_contents($templatePath . '/lang/zh-cn/zh.json', $zhContent . PHP_EOL);
|
||||
|
||||
$enContent = json_encode([
|
||||
$pluginName => [
|
||||
'title' => $pluginTitle,
|
||||
'description' => 'Plugin demo page',
|
||||
'card_title' => $pluginTitle . ' Data',
|
||||
'refresh_btn' => 'Refresh',
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
file_put_contents($templatePath . '/lang/en/en.json', $enContent . PHP_EOL);
|
||||
|
||||
// 生成页面文件
|
||||
$viewStubPath = __DIR__ . self::STUB_PATHS['view'];
|
||||
$viewContent = $this->renderStub($viewStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
]);
|
||||
file_put_contents($templatePath . '/views/' . $pluginKey . '/index/index.vue', $viewContent);
|
||||
|
||||
// 生成路由文件
|
||||
$routesStubPath = __DIR__ . self::STUB_PATHS['routes'];
|
||||
$routesContent = $this->renderStub($routesStubPath, [
|
||||
'{{pluginName}}' => $pluginName,
|
||||
'{{camelPluginName}}' => $camelPluginName,
|
||||
'{{pluginKey}}' => $pluginKey,
|
||||
'{{pluginTitle}}' => $pluginTitle,
|
||||
]);
|
||||
file_put_contents($templatePath . '/routes/index.ts', $routesContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染模板文件
|
||||
*
|
||||
* @param string $stubPath 模板文件路径
|
||||
* @param array $replacements 替换内容数组
|
||||
*
|
||||
* @return string 渲染后的内容
|
||||
*/
|
||||
private function renderStub(string $stubPath, array $replacements): string
|
||||
{
|
||||
if (!file_exists($stubPath)) {
|
||||
throw new \RuntimeException("模板文件不存在: {$stubPath}");
|
||||
}
|
||||
|
||||
$content = file_get_contents($stubPath);
|
||||
return str_replace(array_keys($replacements), array_values($replacements), $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为驼峰命名
|
||||
*
|
||||
* @param string $string 原始字符串
|
||||
*
|
||||
* @return string 驼峰命名的字符串
|
||||
*/
|
||||
private function toCamelCase(string $string): string
|
||||
{
|
||||
return str_replace(' ', '', ucwords(str_replace('_', ' ', $string)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为短横线命名
|
||||
*
|
||||
* @param string $string 原始字符串
|
||||
*
|
||||
* @return string 短横线命名的字符串
|
||||
*/
|
||||
private function toKebabCase(string $string): string
|
||||
{
|
||||
return strtolower(str_replace('_', '-', $string));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件前端路径
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
* @param string $frontendType 前端类型,默认为 admin
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getPluginFrontendPath(string $pluginName, string $frontendType = 'admin'): string
|
||||
{
|
||||
// 使用 base_path() 获取项目根目录
|
||||
$basePath = base_path();
|
||||
|
||||
// 处理已知类型
|
||||
if (FrontendType::isKnownType($frontendType)) {
|
||||
$typeEnum = FrontendType::from($frontendType);
|
||||
return dirname($basePath) . '/' . sprintf($typeEnum->pathTemplate(), $pluginName);
|
||||
}
|
||||
// 处理未知类型(使用默认格式)
|
||||
return dirname($basePath) . "/{$frontendType}/src/apps/{$pluginName}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建插件
|
||||
*
|
||||
* @param string $pluginKey 插件键名(kebab-case 格式,如 test-demo)
|
||||
* @param string|null $targetDirectory 目标目录路径(可选,默认为 runtime/plugins)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function build(string $pluginKey, ?string $targetDirectory = null): array
|
||||
{
|
||||
try {
|
||||
// 将 kebab-case 转换为 snake_case 作为插件名称
|
||||
$pluginName = str_replace('-', '_', $pluginKey);
|
||||
|
||||
// 定义路径
|
||||
$sourceBackendPath = $this->getBackendPath($pluginName);
|
||||
// 尝试不同的前端类型路径
|
||||
$frontendTypes = ['admin', 'web'];
|
||||
$foundFrontendPath = null;
|
||||
$foundFrontendType = null;
|
||||
|
||||
foreach ($frontendTypes as $type) {
|
||||
$path = $this->getPluginFrontendPath($pluginName, $type);
|
||||
if (is_dir($path)) {
|
||||
$foundFrontendPath = $path;
|
||||
$foundFrontendType = $type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用临时构建目录,不删除插件本身
|
||||
$buildPath = runtime_path(self::getPluginBuildPath() . '/' . $pluginKey);
|
||||
|
||||
// 确定目标目录
|
||||
if ($targetDirectory === null) {
|
||||
$targetDirectory = runtime_path(self::getPluginPackagesPath());
|
||||
}
|
||||
|
||||
// 确保目标目录存在
|
||||
if (!is_dir($targetDirectory)) {
|
||||
mkdir($targetDirectory, 0755, true);
|
||||
}
|
||||
|
||||
$zipFilePath = $targetDirectory . '/' . $pluginKey . '.zip';
|
||||
|
||||
// 验证源目录是否存在
|
||||
if (!is_dir($sourceBackendPath)) {
|
||||
throw new \RuntimeException("插件源目录不存在: {$sourceBackendPath}");
|
||||
}
|
||||
|
||||
// 创建构建目录结构
|
||||
$this->createBuildDirectories($buildPath);
|
||||
|
||||
// 复制后端资源
|
||||
$this->copyDirectory($sourceBackendPath . '/app', $buildPath . '/app');
|
||||
$this->copyDirectory($sourceBackendPath . '/config', $buildPath . '/config');
|
||||
// 复制其他必要文件(如 Install.php, public, resource 等)
|
||||
$this->copyFileIfExists($sourceBackendPath . '/Install.php', $buildPath . '/Install.php');
|
||||
if (is_dir($sourceBackendPath . '/public')) {
|
||||
$this->copyDirectory($sourceBackendPath . '/public', $buildPath . '/public');
|
||||
}
|
||||
if (is_dir($sourceBackendPath . '/resource')) {
|
||||
$this->copyDirectory($sourceBackendPath . '/resource', $buildPath . '/resource');
|
||||
}
|
||||
|
||||
// 复制前端资源到 resource/template 目录
|
||||
if ($foundFrontendPath && $foundFrontendType) {
|
||||
$templateTargetDir = $buildPath . '/resource/template/' . $foundFrontendType;
|
||||
if (is_dir($templateTargetDir)) {
|
||||
$this->copyDirectory($foundFrontendPath, $templateTargetDir);
|
||||
}
|
||||
}
|
||||
|
||||
// 压缩构建结果为 zip 文件
|
||||
$this->createZipArchive($buildPath, $zipFilePath);
|
||||
|
||||
// 清理构建目录
|
||||
$this->deleteDirectory($buildPath);
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'message' => '插件构建成功',
|
||||
'data' => [
|
||||
'build_path' => $buildPath,
|
||||
'target_directory' => $targetDirectory,
|
||||
'zip_file_path' => $zipFilePath,
|
||||
'plugin_key' => $pluginKey,
|
||||
],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'code' => 500,
|
||||
'message' => '插件构建失败: ' . $e->getMessage(),
|
||||
'data' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制目录
|
||||
*
|
||||
* @param string $source 源目录
|
||||
* @param string $target 目标目录
|
||||
*/
|
||||
private function copyDirectory(string $source, string $target): void
|
||||
{
|
||||
if (!is_dir($source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir($target)) {
|
||||
mkdir($target, 0755, true);
|
||||
}
|
||||
|
||||
$files = scandir($source);
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourceFile = $source . '/' . $file;
|
||||
$targetFile = $target . '/' . $file;
|
||||
|
||||
if (is_dir($sourceFile)) {
|
||||
$this->copyDirectory($sourceFile, $targetFile);
|
||||
} else {
|
||||
copy($sourceFile, $targetFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文件(如果存在)
|
||||
*
|
||||
* @param string $sourceFile 源文件路径
|
||||
* @param string $targetFile 目标文件路径
|
||||
*/
|
||||
private function copyFileIfExists(string $sourceFile, string $targetFile): void
|
||||
{
|
||||
if (file_exists($sourceFile)) {
|
||||
// 确保目标目录存在
|
||||
$targetDir = dirname($targetFile);
|
||||
if (!is_dir($targetDir)) {
|
||||
mkdir($targetDir, 0755, true);
|
||||
}
|
||||
copy($sourceFile, $targetFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除目录
|
||||
*
|
||||
* @param string $directory 目录路径
|
||||
*/
|
||||
private function deleteDirectory(string $directory): void
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = scandir($directory);
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $directory . '/' . $file;
|
||||
if (is_dir($path)) {
|
||||
$this->deleteDirectory($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
rmdir($directory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 ZIP 压缩文件
|
||||
*
|
||||
* @param string $sourcePath 源目录路径
|
||||
* @param string $zipFilePath ZIP 文件路径
|
||||
*/
|
||||
private function createZipArchive(string $sourcePath, string $zipFilePath): void
|
||||
{
|
||||
// 如果 ZIP 文件已存在,先删除
|
||||
if (file_exists($zipFilePath)) {
|
||||
unlink($zipFilePath);
|
||||
}
|
||||
|
||||
// 创建 ZIP 归档对象
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipFilePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException("无法创建 ZIP 文件: {$zipFilePath}");
|
||||
}
|
||||
|
||||
// 获取源目录的绝对路径
|
||||
$sourcePath = realpath($sourcePath);
|
||||
if ($sourcePath === false) {
|
||||
throw new \RuntimeException("无法获取源目录的绝对路径: {$sourcePath}");
|
||||
}
|
||||
|
||||
// 遍历源目录并添加文件到 ZIP
|
||||
$files = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($sourcePath),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($files as $file) {
|
||||
// 跳过目录
|
||||
if (!$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取文件的绝对路径
|
||||
$filePath = $file->getRealPath();
|
||||
if ($filePath === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算 ZIP 中的相对路径
|
||||
$relativePath = substr($filePath, strlen($sourcePath) + 1);
|
||||
|
||||
// 添加文件到 ZIP
|
||||
if (!$zip->addFile($filePath, $relativePath)) {
|
||||
throw new \RuntimeException("无法添加文件到 ZIP: {$filePath}");
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭 ZIP 归档
|
||||
if (!$zip->close()) {
|
||||
throw new \RuntimeException("无法关闭 ZIP 文件: {$zipFilePath}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取后端路径
|
||||
*
|
||||
* @param string $pluginName 插件名称
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getBackendPath(string $pluginName): string
|
||||
{
|
||||
return base_path() . '/plugin/' . $pluginName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建构建目录结构
|
||||
*
|
||||
* @param string $buildPath 构建路径
|
||||
*/
|
||||
private function createBuildDirectories(string $buildPath): void
|
||||
{
|
||||
$directories = [
|
||||
$buildPath . '/app/controller',
|
||||
$buildPath . '/app/service/admin',
|
||||
$buildPath . '/app/service/api',
|
||||
$buildPath . '/app/validate',
|
||||
$buildPath . '/app/dao',
|
||||
$buildPath . '/app/model',
|
||||
$buildPath . '/app/schema/request',
|
||||
$buildPath . '/app/schema/response',
|
||||
$buildPath . '/config',
|
||||
$buildPath . '/resource/template/admin',
|
||||
$buildPath . '/resource/template/web',
|
||||
];
|
||||
|
||||
$this->createDirectories($directories);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<?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 core\exception\handler\PluginException;
|
||||
use core\tool\Util;
|
||||
use ZipArchive;
|
||||
use GuzzleHttp\Client;
|
||||
|
||||
/**
|
||||
* 插件下载服务
|
||||
* 职责:下载插件ZIP包到插件目录并解压
|
||||
*/
|
||||
final class PluginDownloadService extends PluginBaseService
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载插件到插件目录并解压
|
||||
*
|
||||
* @param string $pluginCode 插件编码(name)
|
||||
* @param string $downloadUrl 下载链接
|
||||
*
|
||||
* @return array 返回插件信息
|
||||
* @throws PluginException
|
||||
*/
|
||||
public function downloadAndExtract(string $pluginCode, string $downloadUrl): array
|
||||
{
|
||||
// 1. 准备下载路径
|
||||
$pluginDir = $this->plugin_path . DIRECTORY_SEPARATOR . $pluginCode;
|
||||
$zipFile = $this->plugin_path . DIRECTORY_SEPARATOR . $pluginCode . '.zip';
|
||||
|
||||
// 2. 创建插件目录(如果不存在)
|
||||
if (!is_dir($this->plugin_path) && !mkdir($this->plugin_path, 0755, true)) {
|
||||
throw new PluginException("无法创建插件目录: {$this->plugin_path}");
|
||||
}
|
||||
|
||||
// 如果插件目录已存在,先删除
|
||||
if (is_dir($pluginDir)) {
|
||||
$this->deleteDirectory($pluginDir);
|
||||
}
|
||||
|
||||
// 3. 下载ZIP文件
|
||||
$this->downloadZipFile($downloadUrl, $zipFile);
|
||||
|
||||
// 4. 解压ZIP到插件目录
|
||||
$this->extractZipFile($zipFile, $this->plugin_path);
|
||||
|
||||
// 5. 读取插件配置信息
|
||||
$pluginInfo = $this->readPluginInfo($pluginCode);
|
||||
|
||||
return [
|
||||
'plugin_code' => $pluginCode,
|
||||
'plugin_dir' => $pluginDir,
|
||||
'plugin_info' => $pluginInfo,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归删除目录
|
||||
*
|
||||
* @param string $dir 目录路径
|
||||
* @return void
|
||||
*/
|
||||
private function deleteDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = array_diff(scandir($dir), ['.', '..']);
|
||||
foreach ($files as $file) {
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $file;
|
||||
if (is_dir($path)) {
|
||||
$this->deleteDirectory($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动目录内容
|
||||
*
|
||||
* @param string $source 源目录
|
||||
* @param string $target 目标目录
|
||||
* @return void
|
||||
*/
|
||||
private function moveDirectoryContents(string $source, string $target): void
|
||||
{
|
||||
// 确保目标目录存在
|
||||
if (!is_dir($target)) {
|
||||
mkdir($target, 0755, true);
|
||||
}
|
||||
|
||||
$files = array_diff(scandir($source), ['.', '..']);
|
||||
foreach ($files as $file) {
|
||||
$sourcePath = $source . DIRECTORY_SEPARATOR . $file;
|
||||
$targetPath = $target . DIRECTORY_SEPARATOR . $file;
|
||||
|
||||
if (is_dir($sourcePath)) {
|
||||
// 递归移动子目录
|
||||
$this->moveDirectoryContents($sourcePath, $targetPath);
|
||||
} else {
|
||||
// 移动文件
|
||||
rename($sourcePath, $targetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载ZIP文件
|
||||
*
|
||||
* @param string $url 下载URL
|
||||
* @param string $file 保存路径
|
||||
*
|
||||
* @return void
|
||||
* @throws PluginException
|
||||
*/
|
||||
private function downloadZipFile(string $url, string $file): void
|
||||
{
|
||||
$client = new Client([
|
||||
'timeout' => 300, // 下载大文件设置更长的超时时间
|
||||
'verify' => false,
|
||||
]);
|
||||
|
||||
$response = $client->get($url, ['sink' => $file]);
|
||||
$status = $response->getStatusCode();
|
||||
|
||||
if ($status !== 200) {
|
||||
throw new PluginException("下载失败,HTTP状态码: {$status}");
|
||||
}
|
||||
|
||||
// 检查文件是否下载成功
|
||||
if (!file_exists($file) || filesize($file) === 0) {
|
||||
throw new PluginException("下载的文件为空或不存在: {$file}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压ZIP文件
|
||||
*
|
||||
* @param string $zipFile ZIP文件路径
|
||||
* @param string $extractTo 解压目标路径(插件目录)
|
||||
*
|
||||
* @return void
|
||||
* @throws PluginException
|
||||
*/
|
||||
private function extractZipFile(string $zipFile, string $extractTo): void
|
||||
{
|
||||
$hasZipArchive = class_exists(ZipArchive::class, false);
|
||||
|
||||
if (!$hasZipArchive) {
|
||||
$cmd = $this->getUnzipCmd($zipFile, $extractTo);
|
||||
if (!$cmd) {
|
||||
throw new PluginException('请给php安装zip模块或者给系统安装unzip命令');
|
||||
}
|
||||
if (!function_exists('proc_open')) {
|
||||
throw new PluginException('请解除proc_open函数的禁用或者给php安装zip模块');
|
||||
}
|
||||
}
|
||||
|
||||
Util::pauseFileMonitor();
|
||||
try {
|
||||
// 创建临时解压目录
|
||||
$tempDir = $extractTo . '_temp';
|
||||
if (is_dir($tempDir)) {
|
||||
$this->deleteDirectory($tempDir);
|
||||
}
|
||||
mkdir($tempDir, 0755, true);
|
||||
|
||||
// 解压zip到临时目录
|
||||
if ($hasZipArchive) {
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFile) === true) {
|
||||
if (!$zip->extractTo($tempDir)) {
|
||||
throw new PluginException("解压失败: {$zipFile}");
|
||||
}
|
||||
$zip->close();
|
||||
} else {
|
||||
throw new PluginException("无法打开压缩包: {$zipFile}");
|
||||
}
|
||||
} else {
|
||||
$this->unzipWithCmd($cmd);
|
||||
}
|
||||
|
||||
// 处理解压后的目录结构
|
||||
$extractedFiles = scandir($tempDir);
|
||||
$extractedFiles = array_diff($extractedFiles, ['.', '..']);
|
||||
|
||||
// 如果只有一个目录,说明ZIP包含了一个根目录(如 nexus/),需要移动内容
|
||||
if (count($extractedFiles) === 1) {
|
||||
$onlyDir = $tempDir . DIRECTORY_SEPARATOR . $extractedFiles[0];
|
||||
if (is_dir($onlyDir)) {
|
||||
// 移动目录内容到目标位置
|
||||
$this->moveDirectoryContents($onlyDir, $extractTo);
|
||||
$this->deleteDirectory($onlyDir);
|
||||
} else {
|
||||
// 是单个文件,直接移动
|
||||
rename($onlyDir, $extractTo . DIRECTORY_SEPARATOR . $extractedFiles[0]);
|
||||
}
|
||||
} else {
|
||||
// 多个文件,直接移动所有内容
|
||||
foreach ($extractedFiles as $file) {
|
||||
rename($tempDir . DIRECTORY_SEPARATOR . $file, $extractTo . DIRECTORY_SEPARATOR . $file);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除临时目录
|
||||
$this->deleteDirectory($tempDir);
|
||||
|
||||
// 删除临时zip文件
|
||||
if (file_exists($zipFile)) {
|
||||
unlink($zipFile);
|
||||
}
|
||||
} finally {
|
||||
Util::resumeFileMonitor();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取插件配置信息
|
||||
*
|
||||
* @param string $pluginCode 插件编码
|
||||
*
|
||||
* @return array 插件信息
|
||||
* @throws PluginException
|
||||
*/
|
||||
private function readPluginInfo(string $pluginCode): array
|
||||
{
|
||||
$infoFile = $this->plugin_path . DIRECTORY_SEPARATOR . $pluginCode . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'info.php';
|
||||
|
||||
if (!file_exists($infoFile)) {
|
||||
throw new PluginException("插件配置文件不存在: {$infoFile}");
|
||||
}
|
||||
|
||||
$info = include $infoFile;
|
||||
|
||||
if (!is_array($info)) {
|
||||
throw new PluginException("插件配置文件格式错误: {$infoFile}");
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统支持的解压命令
|
||||
*
|
||||
* @param string $zipFile ZIP文件路径
|
||||
* @param string $extractTo 解压目标路径
|
||||
*
|
||||
* @return string|null 解压命令
|
||||
*/
|
||||
private function getUnzipCmd(string $zipFile, string $extractTo): ?string
|
||||
{
|
||||
if ($cmd = $this->findCmd('unzip')) {
|
||||
return "{$cmd} -o -qq {$zipFile} -d {$extractTo}";
|
||||
}
|
||||
|
||||
if ($cmd = $this->findCmd('7z')) {
|
||||
return "{$cmd} x -bb0 -y {$zipFile} -o{$extractTo}";
|
||||
}
|
||||
|
||||
if ($cmd = $this->findCmd('7zz')) {
|
||||
return "{$cmd} x -bb0 -y {$zipFile} -o{$extractTo}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用命令行解压ZIP文件
|
||||
*
|
||||
* @param string $cmd 解压命令
|
||||
*
|
||||
* @return void
|
||||
* @throws PluginException
|
||||
*/
|
||||
private function unzipWithCmd(string $cmd): void
|
||||
{
|
||||
$desc = [
|
||||
0 => ["pipe", "r"],
|
||||
1 => ["pipe", "w"],
|
||||
2 => ["pipe", "w"],
|
||||
];
|
||||
|
||||
$handler = proc_open($cmd, $desc, $pipes);
|
||||
if (!is_resource($handler)) {
|
||||
throw new PluginException("解压zip时出错: proc_open调用失败");
|
||||
}
|
||||
|
||||
$err = fread($pipes[2], 1024);
|
||||
fclose($pipes[2]);
|
||||
proc_close($handler);
|
||||
|
||||
if (!empty($err)) {
|
||||
throw new PluginException("解压zip时出错: {$err}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找系统命令
|
||||
*
|
||||
* @param string $name 命令名称
|
||||
* @param string|null $default 默认值
|
||||
* @param array $extraDirs 额外搜索目录
|
||||
*
|
||||
* @return string|null 命令路径
|
||||
*/
|
||||
private function findCmd(string $name, ?string $default = null, array $extraDirs = []): ?string
|
||||
{
|
||||
if (ini_get('open_basedir')) {
|
||||
$searchPath = array_merge(explode(PATH_SEPARATOR, ini_get('open_basedir')), $extraDirs);
|
||||
$dirs = [];
|
||||
|
||||
foreach ($searchPath as $path) {
|
||||
if (@is_dir($path)) {
|
||||
$dirs[] = $path;
|
||||
} else {
|
||||
if (basename($path) === $name && @is_executable($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$dirs = array_merge(
|
||||
explode(PATH_SEPARATOR, getenv('PATH') ?: getenv('Path')),
|
||||
$extraDirs
|
||||
);
|
||||
}
|
||||
|
||||
$suffixes = [''];
|
||||
if ('\\' === DIRECTORY_SEPARATOR) {
|
||||
$pathExt = getenv('PATHEXT');
|
||||
$suffixes = array_merge(
|
||||
$pathExt ? explode(PATH_SEPARATOR, $pathExt) : ['.exe', '.bat', '.cmd', '.com'],
|
||||
$suffixes
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($suffixes as $suffix) {
|
||||
foreach ($dirs as $dir) {
|
||||
$file = $dir . DIRECTORY_SEPARATOR . $name . $suffix;
|
||||
if (@is_file($file) && ('\\' === DIRECTORY_SEPARATOR || @is_executable($file))) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
<?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 core\exception\handler\AdminException;
|
||||
use madong\helper\Dict;
|
||||
|
||||
/**
|
||||
* 插件远程服务
|
||||
*/
|
||||
final class PluginRemoteService extends PluginBaseService
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 HTTP 客户端
|
||||
*
|
||||
* @param string $baseUri 基础URI
|
||||
*
|
||||
* @return \GuzzleHttp\Client
|
||||
*/
|
||||
private function createHttpClient(string $baseUri): \GuzzleHttp\Client
|
||||
{
|
||||
return new \GuzzleHttp\Client([
|
||||
'base_uri' => $baseUri,
|
||||
'timeout' => 60,
|
||||
'connect_timeout' => 5,
|
||||
'verify' => false,
|
||||
'http_errors' => false,
|
||||
'headers' => [
|
||||
'Referer' => \request()->fullUrl() ?? '',
|
||||
'User-Agent' => 'madong-app-plugin',
|
||||
'Accept' => 'application/json;charset=UTF-8',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取授权市场的购买应用
|
||||
*
|
||||
* @param array $params
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getPurchasedModules(array $params = []): array
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'auth_code' => config('madong.auth_code', ''),
|
||||
'auth_secret' => config('madong.auth_secret', ''),
|
||||
'page' => config('madong.page', 1),
|
||||
'limit' => config('madong.limit', 999),
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
];
|
||||
$args = Dict::of($config);
|
||||
//优先使用传入的参数
|
||||
if (!empty($params['auth_code'])) {
|
||||
$args->put('auth_code', $params['auth_code']);
|
||||
}
|
||||
if (!empty($params['auth_secret'])) {
|
||||
$args->put('auth_secret', $params['auth_secret']);
|
||||
}
|
||||
if (!empty($params['page'])) {
|
||||
$args->put('page', $params['page']);
|
||||
}
|
||||
if (!empty($params['limit'])) {
|
||||
$args->put('limit', $params['limit']);
|
||||
}
|
||||
if (!empty($params['market_host'])) {
|
||||
$args->put('market_host', $params['market_host']);
|
||||
}
|
||||
|
||||
if (empty($args->get('auth_code')) || empty($args->get('auth_secret'))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 所有请求都通过远程 curl 获取
|
||||
return $this->getPurchasedModulesFromRemote($args->toArray());
|
||||
} catch (\Throwable $e) {
|
||||
\support\Log::error('getPurchasedModules error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取远程模块
|
||||
*
|
||||
* @param array $args
|
||||
*
|
||||
* @return array
|
||||
* @throws \GuzzleHttp\Exception\GuzzleException
|
||||
*/
|
||||
public function getPurchasedModulesFromRemote(array $args): array
|
||||
{
|
||||
try {
|
||||
$client = $this->createHttpClient($args['market_host']);
|
||||
|
||||
$sign = md5($args['auth_code'] . $args['auth_secret'] . (string)time());
|
||||
$response = $client->get('/api/madong/apps', [
|
||||
'query' => [
|
||||
'auth_code' => $args['auth_code'],
|
||||
'auth_secret' => $args['auth_secret'],
|
||||
'timestamp' => time(),
|
||||
'sign' => $sign,
|
||||
'page' => 1,
|
||||
'limit' => 999,
|
||||
],
|
||||
]);
|
||||
|
||||
$content = $response->getBody()->getContents();
|
||||
$data = json_decode($content, true);
|
||||
|
||||
// 如果返回格式错误,记录日志并返回空数组
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
\support\Log::error('getPurchasedModulesFromRemote: JSON解析失败 - ' . json_last_error_msg());
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检查并验证返回的数据结构
|
||||
if (!is_array($data)) {
|
||||
\support\Log::error('getPurchasedModulesFromRemote: 返回数据不是数组 - ' . $content);
|
||||
return [];
|
||||
}
|
||||
|
||||
// API返回格式: {code: int, msg: string, data: {items: array, total: int}}
|
||||
if (isset($data['code']) && $data['code'] !== 0) {
|
||||
\support\Log::error('getPurchasedModulesFromRemote: API返回错误 - ' . ($data['msg'] ?? '未知错误'));
|
||||
return [];
|
||||
}
|
||||
|
||||
// 提取 items 列表
|
||||
if (isset($data['data']) && is_array($data['data']) && isset($data['data']['items'])) {
|
||||
$items = $data['data']['items'];
|
||||
if (!is_array($items)) {
|
||||
\support\Log::error('getPurchasedModulesFromRemote: items 不是数组');
|
||||
return [];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
// 没有找到items,返回空数组
|
||||
return [];
|
||||
} catch (\Exception $e) {
|
||||
\support\Log::error('getPurchasedModulesFromRemote error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取远程模块的更新列表
|
||||
*
|
||||
* @param string $authCode 授权码
|
||||
* @param string $secretKey 密钥
|
||||
* @param array $filters 过滤条件
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getRemoteUpdateList(string $authCode, string $secretKey, array $filters = []): array
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'auth_code' => $authCode,
|
||||
'auth_secret' => $secretKey,
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
] + $filters;
|
||||
|
||||
// 所有请求都通过远程 curl 获取
|
||||
return $this->getRemoteUpdateListFromRemote($config);
|
||||
} catch (\Exception) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从远程获取模块更新列表
|
||||
*
|
||||
* @param array $args
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getRemoteUpdateListFromRemote(array $args): array
|
||||
{
|
||||
try {
|
||||
$client = $this->createHttpClient($args['market_host']);
|
||||
|
||||
$sign = md5($args['auth_code'] . $args['auth_secret'] . (string)time());
|
||||
$response = $client->get('/api/madong/apps', [
|
||||
'query' => [
|
||||
'auth_code' => $args['auth_code'],
|
||||
'auth_secret' => $args['auth_secret'],
|
||||
'timestamp' => time(),
|
||||
'sign' => $sign,
|
||||
'page' => 1,
|
||||
'limit' => 999,
|
||||
],
|
||||
]);
|
||||
|
||||
$content = $response->getBody()->getContents();
|
||||
$data = json_decode($content, true);
|
||||
|
||||
// 如果返回格式错误,记录日志并返回空数组
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
\support\Log::error('getRemoteUpdateListFromRemote: JSON解析失败 - ' . json_last_error_msg());
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检查并验证返回的数据结构
|
||||
if (!is_array($data)) {
|
||||
\support\Log::error('getRemoteUpdateListFromRemote: 返回数据不是数组 - ' . $content);
|
||||
return [];
|
||||
}
|
||||
|
||||
// API返回格式: {code: int, msg: string, data: {items: array, total: int}}
|
||||
// 检查code,非0表示错误
|
||||
if (isset($data['code']) && $data['code'] !== 0) {
|
||||
\support\Log::error('getRemoteUpdateListFromRemote: API返回错误 - ' . ($data['msg'] ?? '未知错误'));
|
||||
return [];
|
||||
}
|
||||
|
||||
// 提取 items 列表
|
||||
if (isset($data['data']) && is_array($data['data']) && isset($data['data']['items'])) {
|
||||
$items = $data['data']['items'];
|
||||
if (!is_array($items)) {
|
||||
\support\Log::error('getRemoteUpdateListFromRemote: items 不是数组');
|
||||
return [];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
// 没有找到items,返回空数组
|
||||
return [];
|
||||
} catch (\Exception $e) {
|
||||
\support\Log::error('getRemoteUpdateListFromRemote error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取远程模块下载服务
|
||||
*
|
||||
* @param string $authCode 授权码
|
||||
* @param string $secretKey 密钥
|
||||
* @param string $appId 应用ID
|
||||
* @param string $version 版本号
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getRemoteDownloadService(string $authCode, string $secretKey, string $appId, string $version): array
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'auth_code' => $authCode,
|
||||
'auth_secret' => $secretKey,
|
||||
'app_id' => $appId,
|
||||
'version' => $version,
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
];
|
||||
|
||||
// 所有请求都通过远程 curl 获取
|
||||
return $this->getRemoteDownloadServiceFromRemote($config);
|
||||
} catch (\Exception $e) {
|
||||
\support\Log::error('getRemoteDownloadService error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从远程获取模块下载服务
|
||||
*
|
||||
* @param array $args
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getRemoteDownloadServiceFromRemote(array $args): array
|
||||
{
|
||||
try {
|
||||
$client = $this->createHttpClient($args['market_host']);
|
||||
|
||||
$sign = md5($args['auth_code'] . $args['auth_secret'] . (string)time());
|
||||
|
||||
// 记录请求参数
|
||||
\support\Log::info('getRemoteDownloadServiceFromRemote 请求参数', [
|
||||
'market_host' => $args['market_host'],
|
||||
'app_id' => $args['app_id'],
|
||||
'version' => $args['version'],
|
||||
'auth_code' => $args['auth_code'],
|
||||
'auth_secret_prefix' => substr($args['auth_secret'], 0, 4) . '***',
|
||||
]);
|
||||
|
||||
$response = $client->get('/api/madong/apps/download', [
|
||||
'query' => [
|
||||
'auth_code' => $args['auth_code'],
|
||||
'auth_secret' => $args['auth_secret'],
|
||||
'app_id' => $args['app_id'],
|
||||
'version' => $args['version'],
|
||||
'timestamp' => time(),
|
||||
'sign' => $sign,
|
||||
],
|
||||
]);
|
||||
|
||||
$content = $response->getBody()->getContents();
|
||||
$data = json_decode($content, true);
|
||||
|
||||
// 记录响应内容
|
||||
\support\Log::info('getRemoteDownloadServiceFromRemote 响应内容', [
|
||||
'status_code' => $response->getStatusCode(),
|
||||
'content_length' => strlen($content),
|
||||
'content_preview' => substr($content, 0, 500),
|
||||
]);
|
||||
|
||||
// 如果返回格式错误,记录日志并返回空数组
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
\support\Log::error('getRemoteDownloadServiceFromRemote: JSON解析失败 - ' . json_last_error_msg(), [
|
||||
'content' => $content,
|
||||
]);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检查并验证返回的数据结构
|
||||
if (!is_array($data)) {
|
||||
\support\Log::error('getRemoteDownloadServiceFromRemote: 返回数据不是数组 - ' . $content);
|
||||
return [];
|
||||
}
|
||||
|
||||
// API返回格式: {code: int, msg: string, data: {...}}
|
||||
// 检查code,非0表示错误
|
||||
if (isset($data['code']) && $data['code'] !== 0) {
|
||||
\support\Log::error('getRemoteDownloadServiceFromRemote: API返回错误', [
|
||||
'code' => $data['code'],
|
||||
'msg' => $data['msg'] ?? '未知错误',
|
||||
'data' => $data['data'] ?? null,
|
||||
]);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 确保返回的是下载服务数据
|
||||
if (isset($data['data']) && is_array($data['data'])) {
|
||||
\support\Log::info('getRemoteDownloadServiceFromRemote: 成功获取下载链接', [
|
||||
'has_url' => isset($data['data']['url']),
|
||||
'url_length' => isset($data['data']['url']) ? strlen($data['data']['url']) : 0,
|
||||
]);
|
||||
return $data['data'];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (\Exception $e) {
|
||||
\support\Log::error('getRemoteDownloadServiceFromRemote error', [
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证远程授权
|
||||
*
|
||||
* @param string $authCode 授权码
|
||||
* @param string $secretKey 密钥
|
||||
*
|
||||
* @return array
|
||||
* @throws \GuzzleHttp\Exception\GuzzleException
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function verifyRemoteAuthorization(string $authCode, string $secretKey): array
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'auth_code' => $authCode,
|
||||
'auth_secret' => $secretKey,
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
];
|
||||
|
||||
// 所有请求都通过远程 curl 获取
|
||||
return $this->verifyRemoteAuthorizationFromRemote($config);
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从远程验证授权
|
||||
*
|
||||
* @param array $args
|
||||
*
|
||||
* @return array
|
||||
* @throws \GuzzleHttp\Exception\GuzzleException
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
private function verifyRemoteAuthorizationFromRemote(array $args): array
|
||||
{
|
||||
try {
|
||||
$client = $this->createHttpClient($args['market_host']);
|
||||
|
||||
$sign = md5($args['auth_code'] . $args['auth_secret'] . (string)time());
|
||||
$response = $client->get('/api/madong/authorization/verify', [
|
||||
'query' => [
|
||||
'auth_code' => $args['auth_code'],
|
||||
'auth_secret' => $args['auth_secret'],
|
||||
'timestamp' => time(),
|
||||
'sign' => $sign,
|
||||
],
|
||||
]);
|
||||
|
||||
$content = $response->getBody()->getContents();
|
||||
$data = json_decode($content, true);
|
||||
|
||||
// 如果返回格式错误,记录日志
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \Exception('JSON解析失败: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
// 检查并验证返回的数据结构
|
||||
if (!is_array($data)) {
|
||||
throw new \Exception('返回数据不是数组');
|
||||
}
|
||||
|
||||
// 检查返回的code是否为0
|
||||
if (isset($data['code']) && $data['code'] !== 0) {
|
||||
throw new \Exception($data['msg'] ?? '授权验证失败');
|
||||
}
|
||||
|
||||
// 确保返回的是授权验证数据
|
||||
if (isset($data['data']) && is_array($data['data'])) {
|
||||
return $data['data'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取远程应用升级日志
|
||||
*
|
||||
* @param string $authCode 授权码
|
||||
* @param string $secretKey 密钥
|
||||
* @param string $appCode 应用编码
|
||||
* @param int $limit 限制数量
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getRemoteUpdateLogs(string $authCode, string $secretKey, string $appCode, int $limit = 100): array
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'auth_code' => $authCode,
|
||||
'auth_secret' => $secretKey,
|
||||
'code' => $appCode,
|
||||
'limit' => $limit,
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
];
|
||||
|
||||
// 所有请求都通过远程 curl 获取
|
||||
return $this->getRemoteUpdateLogsFromRemote($config);
|
||||
} catch (\Exception $e) {
|
||||
\support\Log::error('getRemoteUpdateLogs error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从远程获取应用升级日志
|
||||
*
|
||||
* @param array $args
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getRemoteUpdateLogsFromRemote(array $args): array
|
||||
{
|
||||
try {
|
||||
$client = $this->createHttpClient($args['market_host']);
|
||||
|
||||
$sign = md5($args['auth_code'] . $args['auth_secret'] . (string)time());
|
||||
$response = $client->get('/api/madong/apps/update-logs', [
|
||||
'query' => [
|
||||
'auth_code' => $args['auth_code'],
|
||||
'auth_secret' => $args['auth_secret'],
|
||||
'code' => $args['code'],
|
||||
'limit' => $args['limit'],
|
||||
'timestamp' => time(),
|
||||
'sign' => $sign,
|
||||
],
|
||||
]);
|
||||
|
||||
$content = $response->getBody()->getContents();
|
||||
$data = json_decode($content, true);
|
||||
|
||||
// 如果返回格式错误,记录日志并返回空数组
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
\support\Log::error('getRemoteUpdateLogsFromRemote: JSON解析失败 - ' . json_last_error_msg());
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检查并验证返回的数据结构
|
||||
if (!is_array($data)) {
|
||||
\support\Log::error('getRemoteUpdateLogsFromRemote: 返回数据不是数组 - ' . $content);
|
||||
return [];
|
||||
}
|
||||
|
||||
// API返回格式: {code: int, msg: string, data: {...}}
|
||||
// 检查code,非0表示错误
|
||||
if (isset($data['code']) && $data['code'] !== 0) {
|
||||
\support\Log::error('getRemoteUpdateLogsFromRemote: API返回错误 - ' . ($data['msg'] ?? '未知错误'));
|
||||
return [];
|
||||
}
|
||||
|
||||
// 确保返回的是升级日志数据
|
||||
if (isset($data['data']) && is_array($data['data'])) {
|
||||
return $data['data'];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (\Exception $e) {
|
||||
\support\Log::error('getRemoteUpdateLogsFromRemote error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
<?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\dao\plugin\PluginDao;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 插件服务类
|
||||
*
|
||||
* @package app\service\core\plugin
|
||||
*/
|
||||
final class PluginService extends PluginBaseService
|
||||
{
|
||||
|
||||
/**
|
||||
* 插件根目录
|
||||
*/
|
||||
protected string $plugin_path;
|
||||
|
||||
/**
|
||||
* 项目根目录(前端和后端的父级目录)
|
||||
*/
|
||||
protected string $project_path;
|
||||
|
||||
/**
|
||||
* 后端根目录
|
||||
*/
|
||||
protected string $server_path;
|
||||
|
||||
public function __construct(PluginDao $dao)
|
||||
{
|
||||
// 初始化基础路径
|
||||
$this->plugin_path = base_path('plugin');
|
||||
$this->project_path = dirname(base_path()); // 项目根目录(前端和后端的父级)
|
||||
$this->server_path = base_path(); // 后端根目录
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件列表
|
||||
*
|
||||
* @param string|null $category 插件分类(all/installed/un_installed/purchased/updatable)
|
||||
* @param string|null $type 插件类型
|
||||
* @param string|null $keyword 搜索关键词
|
||||
* @param int $page 页码
|
||||
* @param int $limit 每页数量
|
||||
*
|
||||
* @return array 插件列表
|
||||
*/
|
||||
public function getList(string|null $category = 'all', string|null $type = null, string|null $keyword = null, int $page = 1, int $limit = 9999): array
|
||||
{
|
||||
// 获取授权配置
|
||||
$config = [
|
||||
'auth_code' => config('madong.auth_code', ''),
|
||||
'auth_secret' => config('madong.auth_secret', ''),
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
'name' => $keyword,
|
||||
];
|
||||
// 获取本地插件列表(原始数据)
|
||||
$localModules = $this->getLocalModules($config['auth_code']);
|
||||
/** @var PluginRemoteService $pluginRemoteService */
|
||||
// 获取已购买的插件列表(原始数据)
|
||||
$pluginRemoteService = Container::make(PluginRemoteService::class);
|
||||
$purchasedModules = $pluginRemoteService->getPurchasedModules($config);
|
||||
// 从本地插件中派生出已安装的模块
|
||||
$installedModules = array_filter($localModules, function ($module) {
|
||||
return $module['is_installed'] === true;
|
||||
});
|
||||
// 构建已安装插件的映射
|
||||
$installedMap = array_column($installedModules, null, 'name');
|
||||
|
||||
// 合并本地和远程市场的插件
|
||||
$allModules = $this->mergeModules($localModules, $purchasedModules);
|
||||
|
||||
// 根据不同类型处理数据
|
||||
$processedItems = [];
|
||||
switch ($category) {
|
||||
case 'installed':
|
||||
// 已安装(本地已安装状态)
|
||||
$processedItems = $this->getInstalledModulesData($allModules, $installedMap, $installedModules);
|
||||
break;
|
||||
case 'un_installed':
|
||||
// 未安装(本地未安装状态)
|
||||
$processedItems = $this->getUninstalledModulesData($localModules, $installedMap);
|
||||
break;
|
||||
case 'purchased':
|
||||
// 已购买(远程市场返回的所有)
|
||||
$processedItems = $this->getPurchasedModulesData($purchasedModules, $installedMap);
|
||||
break;
|
||||
case 'updatable':
|
||||
// 可更新(远程市场跟本地安装对比 市场版本大于安装版本的状态)
|
||||
$processedItems = $this->getUpdatableModulesData($allModules, $installedMap, $installedModules);
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
// 全部(本地+市场)
|
||||
$processedItems = $this->getAllModulesData($allModules, $installedMap);
|
||||
break;
|
||||
}
|
||||
|
||||
// 应用通用过滤
|
||||
$filteredItems = [];
|
||||
foreach ($processedItems as $module) {
|
||||
if ($type !== null && (string)$module['status'] !== (string)$type) {
|
||||
continue;
|
||||
}
|
||||
if ($keyword && !$this->matchKeyword($module, $keyword)) {
|
||||
continue;
|
||||
}
|
||||
$filteredItems[] = $module;
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
$total = count($filteredItems);
|
||||
$items = array_slice($filteredItems, ($page - 1) * $limit, $limit);
|
||||
return compact('page', 'limit', 'total', 'items');
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并本地和远程市场的插件
|
||||
*
|
||||
* @param array $localModules 本地插件列表
|
||||
* @param array $purchasedModules 已购买的插件列表
|
||||
*
|
||||
* @return array 合并后的插件列表
|
||||
*/
|
||||
private function mergeModules(array $localModules, array $purchasedModules): array
|
||||
{
|
||||
$merged = [];
|
||||
$moduleMap = [];
|
||||
|
||||
// 先添加本地插件
|
||||
foreach ($localModules as $module) {
|
||||
$merged[] = $module;
|
||||
$moduleMap[$module['name']] = count($merged) - 1;
|
||||
}
|
||||
|
||||
// 再添加远程市场的插件(如果本地没有)
|
||||
foreach ($purchasedModules as $module) {
|
||||
if (!isset($moduleMap[$module['name']])) {
|
||||
$merged[] = $module;
|
||||
}
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地插件列表(从 plugin 目录扫描)
|
||||
*
|
||||
* @param string $authCode 授权码
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getLocalModules(string $authCode = ''): array
|
||||
{
|
||||
$modules = [];
|
||||
$pluginPath = $this->plugin_path;
|
||||
|
||||
if (!is_dir($pluginPath)) {
|
||||
return $modules;
|
||||
}
|
||||
|
||||
$dirItems = scandir($pluginPath);
|
||||
foreach ($dirItems as $key => $item) {
|
||||
if ($item === '.' || $item === '..' || !is_dir($pluginPath . DIRECTORY_SEPARATOR . $item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$itemPath = $pluginPath . DIRECTORY_SEPARATOR . $item;
|
||||
$infoPath = $itemPath . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'info.php';
|
||||
$appConfigPath = $itemPath . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'app.php';
|
||||
$installedPath = $itemPath . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'installed.php';
|
||||
|
||||
// 只读取 info.php
|
||||
if (!file_exists($infoPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = include $infoPath;
|
||||
if (!is_array($config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查插件归属类型
|
||||
$pluginType = $config['type'] ?? 'madong';
|
||||
if (!str_starts_with($pluginType, 'madong:') && $pluginType !== 'madong') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pluginKey = $config['name'] ?? $item;
|
||||
$existingPlugin = $this->dao->findByKey($pluginKey);
|
||||
$appConfig = [];
|
||||
if (file_exists($appConfigPath)) {
|
||||
$loadedAppConfig = include $appConfigPath;
|
||||
if (is_array($loadedAppConfig)) {
|
||||
$appConfig = $loadedAppConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查安装状态 - 通过 installed.php 判断
|
||||
$isInstalled = file_exists($installedPath);
|
||||
$installedAt = 0;
|
||||
if ($isInstalled) {
|
||||
$installedConfig = include $installedPath;
|
||||
if (is_array($installedConfig) && !empty($installedConfig['installed_at'])) {
|
||||
$installedAt = strtotime($installedConfig['installed_at']);
|
||||
} else {
|
||||
$installedAt = time();
|
||||
}
|
||||
}
|
||||
|
||||
$icon = $this->getPluginImageBase64($item, 'icon');
|
||||
$cover = $this->getPluginImageBase64($item, 'cover');
|
||||
|
||||
$title = '';
|
||||
$description = $appConfig['description'] ?? ($config['description'] ?? '');
|
||||
$author = $appConfig['author'] ?? ($config['author'] ?? '');
|
||||
$version = $appConfig['version'] ?? ($config['version'] ?? '1.0.0');
|
||||
$type = $appConfig['type'] ?? ($config['type'] ?? 'madong');
|
||||
|
||||
if (!empty($appConfig['title'])) {
|
||||
$title = (string)$appConfig['title'];
|
||||
}
|
||||
|
||||
if ($existingPlugin) {
|
||||
$title = $title ?: (string)($existingPlugin->title ?? '');
|
||||
$description = (string)($existingPlugin->desc ?? $description);
|
||||
$author = (string)($existingPlugin->author ?? $author);
|
||||
$icon = $icon ?: (string)($existingPlugin->icon ?? '');
|
||||
$cover = $cover ?: (string)($existingPlugin->cover ?? '');
|
||||
}
|
||||
|
||||
if ($title === '') {
|
||||
$title = $description !== '' ? $description : $pluginKey;
|
||||
}
|
||||
|
||||
$modules[] = [
|
||||
'id' => $key,
|
||||
'code' => $config['name'] ?? $item,
|
||||
'name' => $config['name'] ?? $item,
|
||||
'title' => $title,
|
||||
'type' => $type,
|
||||
'version' => $version,
|
||||
'status' => $isInstalled ? 1 : 0,
|
||||
'description' => $description,
|
||||
'author' => $author,
|
||||
'price' => 0,
|
||||
'cover' => $cover,
|
||||
'icon' => $icon,
|
||||
'downloads' => 0,
|
||||
'rating' => 0,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
'auth_code' => $authCode,
|
||||
'is_installed' => $isInstalled,
|
||||
'installed_at' => $installedAt,
|
||||
'is_local' => 1,
|
||||
'undeletable' => isset($config['uninstall']['undeletable']) && $config['uninstall']['undeletable'] === true ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
private function getPluginImageBase64(string $pluginName, string $imageName): string
|
||||
{
|
||||
$imagePath = $this->plugin_path . DIRECTORY_SEPARATOR . $pluginName . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $imageName . '.png';
|
||||
if (!file_exists($imagePath)) {
|
||||
$resourcePath = $this->plugin_path . DIRECTORY_SEPARATOR . $pluginName . DIRECTORY_SEPARATOR . 'resource' . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $imageName . '.png';
|
||||
if (!file_exists($resourcePath)) {
|
||||
return '';
|
||||
}
|
||||
$imagePath = $resourcePath;
|
||||
}
|
||||
|
||||
$imageData = file_get_contents($imagePath);
|
||||
if ($imageData === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'data:image/png;base64,' . base64_encode($imageData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已安装模块数据
|
||||
*/
|
||||
private function getInstalledModulesData(array $allModules, array $installedMap, array $installedModules): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($installedModules as $localModule) {
|
||||
// 从localModules派生的installedModules可能没有enable字段,默认为true
|
||||
$enable = $localModule['enable'] ?? true;
|
||||
if (!$enable) continue;
|
||||
|
||||
$name = $localModule['name'];
|
||||
$remoteModule = $this->findModuleByName($allModules, $name);
|
||||
|
||||
$result[] = $this->buildModuleItem(
|
||||
$remoteModule ?: $this->createFallbackModule($localModule),
|
||||
$localModule,
|
||||
$installedMap
|
||||
);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未安装模块数据
|
||||
*/
|
||||
private function getUninstalledModulesData(array $localModules, array $installedMap): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($localModules as $module) {
|
||||
if (isset($installedMap[$module['name']])) continue;
|
||||
|
||||
$result[] = $this->buildModuleItem($module, null, $installedMap);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已购买模块数据
|
||||
*/
|
||||
private function getPurchasedModulesData(array $purchasedModules, array $installedMap): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($purchasedModules as $module) {
|
||||
$localModule = $installedMap[$module['name']] ?? null;
|
||||
$result[] = $this->buildModuleItem($module, $localModule, $installedMap);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可更新模块数据
|
||||
*/
|
||||
private function getUpdatableModulesData(array $allModules, array $installedMap, array $installedModules): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($installedModules as $localModule) {
|
||||
// 从localModules派生的installedModules可能没有enable字段,默认为true
|
||||
$enable = $localModule['enable'] ?? true;
|
||||
if (!$enable) continue;
|
||||
|
||||
$name = $localModule['name'];
|
||||
$remoteModule = $this->findModuleByName($allModules, $name);
|
||||
|
||||
if (!$remoteModule) continue;
|
||||
|
||||
if (version_compare($remoteModule['version'], $localModule['version'], '>')) {
|
||||
$result[] = $this->buildModuleItem($remoteModule, $localModule, $installedMap);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有模块数据
|
||||
*/
|
||||
private function getAllModulesData(array $allModules, array $installedMap): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($allModules as $module) {
|
||||
$localModule = $installedMap[$module['name']] ?? null;
|
||||
$result[] = $this->buildModuleItem($module, $localModule, $installedMap);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建模块数据项(包含安装状态、版本差异等)
|
||||
*/
|
||||
private function buildModuleItem(?array $remoteModule, ?array $localModule, array $installedMap): array
|
||||
{
|
||||
$name = $remoteModule['name'] ?? $localModule['name'] ?? '';
|
||||
$title = $remoteModule['title'] ?? $localModule['title'] ?? $name;
|
||||
$isInstalled = $localModule !== null;
|
||||
$localVersion = $isInstalled ? $localModule['version'] : null;
|
||||
$remoteVersion = $remoteModule['version'] ?? null;
|
||||
|
||||
// 获取本地插件标志
|
||||
$isLocal = ($localModule && isset($localModule['is_local'])) ? $localModule['is_local'] : 0;
|
||||
|
||||
// 获取不可删除标志(优先从 localModule 获取)
|
||||
$undeletable = 0;
|
||||
if ($localModule && isset($localModule['undeletable'])) {
|
||||
$undeletable = $localModule['undeletable'];
|
||||
}
|
||||
|
||||
// 计算可更新状态
|
||||
$hasUpdate = false;
|
||||
$versionComparison = null;
|
||||
if ($isInstalled && $remoteVersion) {
|
||||
$hasUpdate = version_compare($remoteVersion, $localVersion, '>');
|
||||
$versionComparison = $hasUpdate ? "{$remoteVersion} > {$localVersion}" : null;
|
||||
}
|
||||
|
||||
// 计算购买状态
|
||||
$isPurchased = false;
|
||||
if ($remoteModule) {
|
||||
$isPurchased = $remoteModule['price'] == 0 ||
|
||||
in_array($remoteModule['name'], ['sms-verification', 'wechat-integration']);
|
||||
}
|
||||
|
||||
// 统一数据结构 - 使用专业命名
|
||||
return [
|
||||
'id' => $remoteModule['id'] ?? $localModule['name'] ?? '',
|
||||
'code' => $remoteModule['code'] ?? $localModule['name'] ?? '',
|
||||
'name' => $name,
|
||||
'title' => $title,
|
||||
'type' => $remoteModule['type'] ?? ($localModule['type'] ?? 'module'),
|
||||
'version' => $remoteVersion ?? $localVersion ?? '',
|
||||
'status' => $remoteModule['status'] ?? 1,
|
||||
'description' => $remoteModule['description'] ?? ($localModule['description'] ?? ''),
|
||||
'detail_description' => $remoteModule['detail_description'] ?? '',
|
||||
'author' => $remoteModule['author'] ?? ($localModule['author'] ?? ''),
|
||||
'icon' => $remoteModule['icon'] ?? ($localModule['icon'] ?? ''),
|
||||
'cover' => $remoteModule['cover'] ?? ($localModule['cover'] ?? ''),
|
||||
'poster' => $remoteModule['poster'] ?? ($localModule['poster'] ?? ($localModule['cover'] ?? '')),
|
||||
'price' => $remoteModule['price'] ?? 0,
|
||||
'downloads' => $remoteModule['downloads'] ?? 0,
|
||||
'rating' => $remoteModule['rating'] ?? 0,
|
||||
'created_at' => $remoteModule['created_at'] ?? date('Y-m-d H:i:s'),
|
||||
'updated_at' => $remoteModule['updated_at'] ?? date('Y-m-d H:i:s'),
|
||||
'update_time' => $remoteModule['update_time'] ?? '',
|
||||
'update_logs' => $remoteModule['update_logs'] ?? [],
|
||||
'category' => $remoteModule['category'] ?? null,
|
||||
'category_name' => $remoteModule['category_name'] ?? '',
|
||||
'tags' => $remoteModule['tags'] ?? [],
|
||||
'is_new' => (int)($remoteModule['is_new'] ?? 0),
|
||||
'is_hot' => (int)($remoteModule['is_hot'] ?? 0),
|
||||
'purchased' => (int)($remoteModule['purchased'] ?? 0),
|
||||
'installed' => (int)($remoteModule['installed'] ?? 0),
|
||||
'manual_uninstall' => (int)($remoteModule['manual_uninstall'] ?? 0),
|
||||
'composer_dependencies' => $remoteModule['composer_dependencies'] ?? [],
|
||||
'npm_dependencies' => $remoteModule['npm_dependencies'] ?? [],
|
||||
|
||||
// 状态标识 - 使用更专业的命名
|
||||
'is_installed' => (int)$isInstalled,
|
||||
'installed_version' => $localVersion,
|
||||
'has_update' => (int)$hasUpdate,
|
||||
'is_purchased' => (int)$isPurchased,
|
||||
'is_downloaded' => (int)($isInstalled || in_array($name, ['data-export'])),
|
||||
'can_uninstall' => (int)($isInstalled && $name !== 'user-management'),
|
||||
'can_download' => (int)$isPurchased,
|
||||
'is_local' => (int)$isLocal,
|
||||
'undeletable' => $undeletable,
|
||||
|
||||
// 专业化的版本信息结构
|
||||
'version_info' => [
|
||||
'remote' => [
|
||||
'version' => $remoteVersion,
|
||||
'release_date' => $remoteModule['updated_at'] ?? null,
|
||||
],
|
||||
'local' => [
|
||||
'version' => $localVersion,
|
||||
'install_date' => $localModule['created_at'] ?? null,
|
||||
],
|
||||
'comparison' => [
|
||||
'needs_update' => (int)$hasUpdate,
|
||||
'is_latest' => (int)($isInstalled && $remoteVersion && version_compare($remoteVersion, $localVersion, '<=')),
|
||||
'version_difference' => $versionComparison,
|
||||
],
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过名称查找模块
|
||||
*/
|
||||
private function findModuleByName(array $modules, string $name): ?array
|
||||
{
|
||||
foreach ($modules as $module) {
|
||||
if ($module['name'] === $name) {
|
||||
return $module;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建回退模块(当远程模块不存在时使用)
|
||||
*/
|
||||
private function createFallbackModule(array $localModule): array
|
||||
{
|
||||
return [
|
||||
'name' => $localModule['name'],
|
||||
'title' => $localModule['title'] ?? $localModule['name'],
|
||||
'type' => 'module',
|
||||
'version' => $localModule['version'],
|
||||
'status' => 1,
|
||||
'description' => $localModule['description'] ?? '',
|
||||
'author' => $localModule['author'] ?? '',
|
||||
'price' => 0,
|
||||
'cover' => $localModule['cover'] ?? '',
|
||||
'icon' => $localModule['icon'] ?? '',
|
||||
'downloads' => 0,
|
||||
'rating' => 0,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
'undeletable' => $localModule['undeletable'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配模块名称、描述和作者是否包含关键词
|
||||
*/
|
||||
private function matchKeyword(array $module, string $keyword): bool
|
||||
{
|
||||
$lowerKeyword = strtolower($keyword);
|
||||
$title = strtolower((string)($module['title'] ?? ''));
|
||||
return str_contains(strtolower($module['name']), $lowerKeyword) ||
|
||||
str_contains($title, $lowerKeyword) ||
|
||||
str_contains(strtolower($module['description']), $lowerKeyword) ||
|
||||
str_contains(strtolower($module['author']), $lowerKeyword);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块升级日志
|
||||
*
|
||||
* @param string $moduleName 模块名称
|
||||
*
|
||||
* @return array 升级日志列表
|
||||
*/
|
||||
public function getUpgradeLogs(string $moduleName): array
|
||||
{
|
||||
try {
|
||||
// 获取授权配置
|
||||
$config = [
|
||||
'auth_code' => config('madong.auth_code', ''),
|
||||
'auth_secret' => config('madong.auth_secret', ''),
|
||||
'market_host' => config('madong.market_host', 'https://madong.tech'),
|
||||
];
|
||||
|
||||
/** @var PluginRemoteService $pluginRemoteService */
|
||||
$pluginRemoteService = Container::make(PluginRemoteService::class);
|
||||
return $pluginRemoteService->getRemoteUpdateLogs(
|
||||
$config['auth_code'],
|
||||
$config['auth_secret'],
|
||||
$moduleName
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
*+------------------
|
||||
* 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 core\tool\Sse;
|
||||
use core\uuid\UUIDGenerator;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 插件卸载服务(统一入口)
|
||||
* CLI 和在线卸载都统一调用此服务
|
||||
* CLI 模式通过迭代器获取 SSE 事件并解析
|
||||
* 在线模式直接返回 Generator 给前端处理
|
||||
*/
|
||||
class PluginUninstallService extends PluginBaseService
|
||||
{
|
||||
/**
|
||||
* 卸载插件(统一入口)
|
||||
* 卸载流程统一使用插件自带的 Install.php
|
||||
* PluginUninstallService 只负责流程控制,实际卸载逻辑完全由插件 Install.php 处理
|
||||
*
|
||||
* @param string $code 插件编码
|
||||
*
|
||||
* @return \Generator CLI 模式返回数组,在线模式返回 Generator
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function uninstall(string $code): \Generator
|
||||
{
|
||||
$sessionUuid = UUIDGenerator::generate();
|
||||
$request = request();
|
||||
if ($request) {
|
||||
$sessionUuid = $request->input('uuid', $sessionUuid);
|
||||
}
|
||||
|
||||
// 检查插件实体是否存在
|
||||
$pluginDir = $this->plugin_path . DIRECTORY_SEPARATOR . $code;
|
||||
$pluginConfigFile = $pluginDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'info.php';
|
||||
|
||||
if (!is_dir($pluginDir) || !is_file($pluginConfigFile)) {
|
||||
yield Sse::error('插件不存在,无法卸载', [], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查插件是否允许卸载
|
||||
$pluginConfig = $this->getPluginConfig($code);
|
||||
if (isset($pluginConfig['uninstall']['undeletable']) && $pluginConfig['uninstall']['undeletable'] === true) {
|
||||
yield Sse::error('该插件为系统内置插件,不允许卸载', [], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
$installedConfigPath = $pluginDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'installed.php';
|
||||
if (!is_file($installedConfigPath)) {
|
||||
yield Sse::error('插件已卸载,无需重复执行卸载操作', [], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查插件是否提供 Install.php
|
||||
$className = "plugin\\{$code}\\Install";
|
||||
if (!class_exists($className)) {
|
||||
yield Sse::error("插件未提供 Install.php,无法进行卸载", [], $sessionUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 发送卸载开始事件
|
||||
yield Sse::progress('开始卸载插件', 10, ['plugin' => $code], $sessionUuid);
|
||||
|
||||
// 统一调用插件的 Install::uninstall() 方法(这是唯一的卸载入口)
|
||||
yield Sse::progress('执行插件卸载逻辑', 30, [], $sessionUuid);
|
||||
$version = $pluginConfig['version'] ?? '1.0.0';
|
||||
|
||||
// 实例化并执行插件 Install 类(传入进度回调以支持在线模式反馈)
|
||||
$installInstance = new $className();
|
||||
|
||||
// 创建临时输出文件(类似 Terminal 的方式)
|
||||
$outputFile = runtime_path(self::RUNTIME_PLUGIN_PATH . '/uninstall_' . $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::uninstall() 内部的 echo 输出
|
||||
ob_start();
|
||||
$installInstance->uninstall($version);
|
||||
$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)) {
|
||||
// 使用 Sse::progress 统一输出,不更新进度值
|
||||
yield Sse::progress($line, 30, [], $sessionUuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理临时文件
|
||||
@unlink($outputFile);
|
||||
|
||||
yield Sse::progress('插件卸载方法执行成功', 80, [], $sessionUuid);
|
||||
|
||||
// 删除插件内的安装配置
|
||||
yield Sse::progress('删除安装配置', 90, [], $sessionUuid);
|
||||
if (is_file($installedConfigPath)) {
|
||||
@unlink($installedConfigPath);
|
||||
}
|
||||
|
||||
// 发送卸载完成事件
|
||||
yield Sse::completed('插件卸载成功', ['plugin' => $code], $sessionUuid);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// 发送卸载失败事件
|
||||
yield Sse::error('卸载失败:' . $e->getMessage(), [
|
||||
'exception' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
], $sessionUuid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件(仅删除插件包)
|
||||
* 删除条件:
|
||||
* 1. 插件必须为已卸载状态(installed.php 不存在)
|
||||
* 2. 非官方插件(undeletable != true)
|
||||
*
|
||||
* @param string $code 插件编码
|
||||
*
|
||||
* @return void 删除成功返回 true
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function delete(string $code): void
|
||||
{
|
||||
// 1. 检查插件实体是否存在
|
||||
$pluginDir = $this->plugin_path . DIRECTORY_SEPARATOR . $code;
|
||||
$pluginConfigFile = $pluginDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'info.php';
|
||||
|
||||
if (!is_dir($pluginDir) || !is_file($pluginConfigFile)) {
|
||||
throw new \Exception('插件不存在,无法删除');
|
||||
}
|
||||
|
||||
// 2. 检查插件配置
|
||||
$pluginConfig = $this->getPluginConfig($code);
|
||||
|
||||
// 3. 检查是否为系统内置插件(不允许删除)
|
||||
if (isset($pluginConfig['uninstall']['undeletable']) && $pluginConfig['uninstall']['undeletable'] === true) {
|
||||
throw new \Exception('系统内置插件不允许删除');
|
||||
}
|
||||
|
||||
// 4. 检查插件安装状态(installed.php 不存在)
|
||||
$installedConfigPath = $pluginDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'installed.php';
|
||||
$isInstalled = is_file($installedConfigPath);
|
||||
|
||||
if ($isInstalled) {
|
||||
throw new \Exception('插件已安装,请先卸载后再删除');
|
||||
}
|
||||
|
||||
// 5. 检查插件是否提供 Install.php
|
||||
$className = "plugin\\{$code}\\Install";
|
||||
if (!class_exists($className)) {
|
||||
throw new \Exception("插件未提供 Install.php,无法进行删除");
|
||||
}
|
||||
|
||||
try {
|
||||
$version = $pluginConfig['version'] ?? '1.0.0';
|
||||
|
||||
// 实例化并执行插件 Install 类
|
||||
$installInstance = new $className();
|
||||
|
||||
// 设置为 CLI 模式(不使用回调)
|
||||
$installInstance->setOnlineMode(false);
|
||||
|
||||
// 执行删除
|
||||
$installInstance->delete($version);
|
||||
|
||||
// 删除后,需要清理数据库中的插件记录
|
||||
$this->deletePluginRecord($code);
|
||||
} catch (\Throwable $e) {
|
||||
throw new \Exception('删除失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件数据库记录
|
||||
*/
|
||||
protected function deletePluginRecord(string $code): void
|
||||
{
|
||||
try {
|
||||
/** @var PluginService $service */
|
||||
$service = Container::make(PluginService::class);
|
||||
$model = $service->dao->query()->where('key', $code)->first();
|
||||
if ($model) {
|
||||
$model->delete();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException('删除插件记录失败:' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import request from '@/utils/http';
|
||||
|
||||
/**
|
||||
* {{pluginName}} 插件 API
|
||||
*/
|
||||
export default {
|
||||
/**
|
||||
* 列表 API
|
||||
* @param params 查询参数
|
||||
* @returns 响应数据
|
||||
*/
|
||||
async test(params = {}) {
|
||||
return request.get<{ msg?: string; data?: unknown }>({
|
||||
url: '/{{pluginKey}}/index',
|
||||
params
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"{{pluginName}}": {
|
||||
"title": "{{pluginTitle}}",
|
||||
"description": "{{pluginDescription}}",
|
||||
"list": "列表",
|
||||
"add": "添加",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"detail": "详情",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"search": "搜索",
|
||||
"reset": "重置",
|
||||
"confirm": "确认",
|
||||
"success": "操作成功",
|
||||
"error": "操作失败",
|
||||
"loading": "加载中..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
|
||||
export default {
|
||||
path: '/{{pluginName}}',
|
||||
name: '{{camelPluginName}}',
|
||||
component: RoutesAlias.Layout,
|
||||
meta: {
|
||||
title: '{{pluginTitle}}',
|
||||
icon: 'ri:plugin-line',
|
||||
module: '{{pluginName}}'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
name: '{{camelPluginName}}Index',
|
||||
component: '/{{pluginKey}}/index/index',
|
||||
meta: {
|
||||
title: '首页',
|
||||
icon: 'ri:home-line',
|
||||
keepAlive: false,
|
||||
module: '{{pluginName}}'
|
||||
}
|
||||
}
|
||||
]
|
||||
} as AppRouteRecord
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="{{pluginName}}-container">
|
||||
<div class="plugin-header">
|
||||
<h1>{{ $t('{{pluginName}}.title') }}</h1>
|
||||
<p>{{ $t('{{pluginName}}.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="plugin-content">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ $t('{{pluginName}}.card_title') }}</span>
|
||||
<el-button type="primary" @click="fetchTestData">
|
||||
{{ $t('{{pluginName}}.refresh_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-loading="loading" class="card-body">
|
||||
<div v-if="error" class="error-message">
|
||||
<el-alert
|
||||
:title="error"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="testData" class="plugin-data">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="测试消息">
|
||||
{{ testData.msg }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="数据">
|
||||
{{ testData.data }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<el-empty description="暂无数据" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import pluginApi from '../../../api/index';
|
||||
|
||||
export default defineComponent({
|
||||
name: '{{camelPluginName}}View',
|
||||
setup() {
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const testData = ref<{ msg?: string; data?: unknown } | null>(null);
|
||||
|
||||
const fetchTestData = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await pluginApi.test();
|
||||
testData.value = response;
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : '网络错误';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始加载数据
|
||||
fetchTestData();
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
testData,
|
||||
fetchTestData
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.{{pluginName}}-container {
|
||||
padding: 20px;
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
.plugin-header {
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plugin-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.plugin-header p {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.plugin-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.plugin-data {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.plugin-data pre {
|
||||
background-color: #f5f7fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => '{{pluginName}}',
|
||||
'title' => '{{pluginTitle}}',
|
||||
'type' => 'madong',
|
||||
'description' => '{{pluginDescription}}',
|
||||
'author' => 'Your Name',
|
||||
'email' => 'your.email@example.com',
|
||||
'version' => '1.0.0',
|
||||
// Composer 依赖
|
||||
'require' => [
|
||||
'composer' => [],
|
||||
],
|
||||
// NPM 依赖
|
||||
'npm_require' => [
|
||||
'admin' => [],
|
||||
'web' => [],
|
||||
],
|
||||
|
||||
// 卸载配置
|
||||
'uninstall' => [
|
||||
'undeletable' => false, // 是否不可删除(true: 系统内置插件不可删除, false: 可删除)
|
||||
'drop_tables' => true, // 删除数据表
|
||||
'remove_dependencies' => false, // 移除合并的依赖
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enable' => true,
|
||||
'debug' => true,
|
||||
'controller_suffix' => 'Controller',
|
||||
'controller_reuse' => false,
|
||||
'version' => '1.0.0',
|
||||
];
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/**
|
||||
* {{camelPluginName}} 控制器
|
||||
*/
|
||||
|
||||
namespace plugin\{{pluginName}}\app\adminapi\controller;
|
||||
|
||||
use app\adminapi\middleware\OperationMiddleware;
|
||||
use app\adminapi\middleware\PermissionMiddleware;
|
||||
use core\tool\Json;
|
||||
use madong\swagger\annotation\response\DataResponse;
|
||||
use madong\swagger\annotation\response\SimpleResponse;
|
||||
use madong\swagger\attribute\Permission;
|
||||
use madong\swagger\attribute\AllowAnonymous;
|
||||
use OpenApi\Attributes as OA;
|
||||
use support\annotation\Middleware;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
#[Middleware(OperationMiddleware::class, PermissionMiddleware::class)]
|
||||
class {{camelPluginName}}Controller
|
||||
{
|
||||
#[OA\Get(
|
||||
path: '/{{pluginKey}}/index',
|
||||
summary: '获取列表',
|
||||
tags: ['{{pluginName}}']
|
||||
)]
|
||||
#[DataResponse(description: '获取成功')]
|
||||
#[Permission(code: '{{pluginName}}:index', description: '列表权限')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Json::success('获取成功', []);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/{{pluginKey}}/create',
|
||||
summary: '创建',
|
||||
tags: ['{{pluginName}}']
|
||||
)]
|
||||
#[SimpleResponse(description: '创建成功')]
|
||||
#[Permission(code: '{{pluginName}}:create', description: '创建权限')]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Json::success('创建成功');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/{{pluginKey}}/info',
|
||||
summary: '详情',
|
||||
tags: ['{{pluginName}}']
|
||||
)]
|
||||
#[DataResponse(description: '获取成功')]
|
||||
#[Permission(code: '{{pluginName}}:info', description: '详情权限')]
|
||||
public function info(Request $request): Response
|
||||
{
|
||||
return Json::success('获取成功', []);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/{{pluginKey}}/update',
|
||||
summary: '更新',
|
||||
tags: ['{{pluginName}}']
|
||||
)]
|
||||
#[SimpleResponse(description: '更新成功')]
|
||||
#[Permission(code: '{{pluginName}}:update', description: '更新权限')]
|
||||
public function update(Request $request): Response
|
||||
{
|
||||
return Json::success('更新成功');
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/{{pluginKey}}/delete',
|
||||
summary: '删除',
|
||||
tags: ['{{pluginName}}']
|
||||
)]
|
||||
#[SimpleResponse(description: '删除成功')]
|
||||
#[Permission(code: '{{pluginName}}:delete', description: '删除权限')]
|
||||
public function delete(Request $request): Response
|
||||
{
|
||||
return Json::success('删除成功');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace plugin\{{pluginName}};
|
||||
|
||||
use core\plugin\PluginInstall as BasePluginInstall;
|
||||
|
||||
/**
|
||||
* 插件安装类
|
||||
* 继承 PluginInstall 基类,自动处理:
|
||||
* - 数据库迁移 (resource/database/migrations/)
|
||||
* - 种子数据 (resource/database/seeds/)
|
||||
*
|
||||
* 菜单需要按需导入或卸载;模板在安装时导入,物理删除由删除插件阶段统一处理:
|
||||
* - $this->importPluginMenus() - 导入菜单
|
||||
* - $this->importTemplates() - 复制模板
|
||||
* - $this->uninstallPluginMenus() - 卸载菜单
|
||||
*/
|
||||
class Install extends BasePluginInstall
|
||||
{
|
||||
/**
|
||||
* 安装前回调
|
||||
*/
|
||||
protected function beforeInstall(string $version): void
|
||||
{
|
||||
$this->output(" 🔄 Running beforeInstall...\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装后回调 - 按需导入菜单和模板
|
||||
*/
|
||||
protected function afterInstall(string $version): void
|
||||
{
|
||||
$this->output(" 🔄 Running afterInstall...\n");
|
||||
|
||||
// 导入菜单
|
||||
$this->importPluginMenus();
|
||||
|
||||
// 复制模板资源
|
||||
$this->importTemplates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载前回调
|
||||
*/
|
||||
protected function beforeUninstall(string $version): void
|
||||
{
|
||||
$this->output(" 🔄 Running beforeUninstall...\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载后回调 - 清理菜单
|
||||
*/
|
||||
protected function afterUninstall(string $version): void
|
||||
{
|
||||
$this->output(" 🔄 Running afterUninstall...\n");
|
||||
// 卸载菜单
|
||||
$this->uninstallPluginMenus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 后台菜单配置
|
||||
* 安装时会自动加载
|
||||
*/
|
||||
|
||||
return [
|
||||
[
|
||||
'name' => '{{pluginTitle}}',
|
||||
'code' => '{{pluginKey}}',
|
||||
'app' => '{{pluginName}}',
|
||||
'path' => '/{{pluginKey}}',
|
||||
'icon' => 'plugin',
|
||||
'children' => [
|
||||
[
|
||||
'name' => '列表',
|
||||
'code' => '{{pluginKey}}:index',
|
||||
'app' => '{{pluginName}}',
|
||||
'path' => '/{{pluginKey}}/index',
|
||||
'component' => '/{{pluginKey}}/index/index',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 前端菜单配置
|
||||
*/
|
||||
|
||||
return [
|
||||
// 前端菜单配置
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Web 菜单配置
|
||||
*/
|
||||
|
||||
return [
|
||||
// Web 菜单配置
|
||||
];
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* {{pluginName}} 插件路由配置
|
||||
* 使用 Swagger 注解自动生成路由
|
||||
*/
|
||||
|
||||
use Webman\Route;
|
||||
use WebmanTech\Swagger\Swagger;
|
||||
use OpenApi\Annotations as OA;
|
||||
|
||||
/**
|
||||
* 注册 API 路由
|
||||
*/
|
||||
Route::group('/api', function () {
|
||||
Swagger::create()->registerRoute([
|
||||
'route_prefix' => '/{{pluginKey}}/openapi',
|
||||
'register_route' => true,
|
||||
'openapi_doc' => [
|
||||
'scan_path' => [
|
||||
base_path('plugin/{{pluginName}}/app/api'),
|
||||
],
|
||||
'modify' => function (OA\OpenApi $openapi) {
|
||||
$openapi->info->title = '{{pluginTitle}} API';
|
||||
$openapi->info->version = '1.0.0';
|
||||
$openapi->servers = [
|
||||
new OA\Server(
|
||||
[
|
||||
'url' => '/api',
|
||||
'description' => request()->host(),
|
||||
]
|
||||
),
|
||||
];
|
||||
/** @phpstan-ignore-next-line */
|
||||
if (!$openapi->components instanceof OA\Components) {
|
||||
$openapi->components = new OA\Components([]);
|
||||
}
|
||||
$openapi->components->securitySchemes = [
|
||||
new OA\SecurityScheme([
|
||||
'securityScheme' => 'api_key',
|
||||
'type' => 'apiKey',
|
||||
'name' => config('core.jwt.app.token_name', 'Authorization'),
|
||||
'in' => 'header',
|
||||
]),
|
||||
];
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 注册 AdminAPI 路由
|
||||
*/
|
||||
Route::group('/adminapi', function () {
|
||||
Swagger::create()->registerRoute([
|
||||
'route_prefix' => '/{{pluginKey}}/openapi',
|
||||
'register_route' => true,
|
||||
'openapi_doc' => [
|
||||
'scan_path' => [
|
||||
base_path('plugin/{{pluginName}}/app/adminapi'),
|
||||
],
|
||||
'modify' => function (OA\OpenApi $openapi) {
|
||||
$openapi->info->title = '{{pluginTitle}} API';
|
||||
$openapi->info->version = '1.0.0';
|
||||
$openapi->servers = [
|
||||
new OA\Server(
|
||||
[
|
||||
'url' => '/adminapi',
|
||||
'description' => request()->host(),
|
||||
]
|
||||
),
|
||||
];
|
||||
/** @phpstan-ignore-next-line */
|
||||
if (!$openapi->components instanceof OA\Components) {
|
||||
$openapi->components = new OA\Components([]);
|
||||
}
|
||||
$openapi->components->securitySchemes = [
|
||||
new OA\SecurityScheme([
|
||||
'securityScheme' => 'api_key',
|
||||
'type' => 'apiKey',
|
||||
'name' => config('core.jwt.app.token_name', 'Authorization'),
|
||||
'in' => 'header',
|
||||
]),
|
||||
];
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'locale' => config('app.lang'),
|
||||
'fallback_locale' => ['zh_CN', 'en'],
|
||||
'path' => base_path() . '/plugin/{{pluginName}}/resource/translations',
|
||||
];
|
||||
Reference in New Issue
Block a user