初始化本地仓库

This commit is contained in:
2026-08-31 11:33:59 +08:00
commit 663f74b633
2390 changed files with 402918 additions and 0 deletions
@@ -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',
];