// +---------------------------------------------------------------------- declare(strict_types=1); namespace app\adminapi\controller\plugin; use app\adminapi\controller\AuthController; use app\services\system\module\ModuleInstaller; use app\services\system\module\CreateModuleInstallLogTable; use crmeb\plugin\PluginManager; use crmeb\plugin\PluginManifest; use crmeb\utils\SafeZip; use think\facade\App; /** * 插件管理控制器 * 管理插件的安装/卸载/启用/禁用/上传 * Class PluginController * @package app\adminapi\controller\plugin */ class PluginController extends AuthController { /** * @var ModuleInstaller */ protected $services; /** * @param App $app * @param ModuleInstaller $services */ public function __construct(App $app, ModuleInstaller $services) { parent::__construct($app); $this->services = $services; } /** * 初始化 */ protected function initialize() { parent::initialize(); // 确保安装日志表 + enabled 字段存在 (new CreateModuleInstallLogTable())->up(); $this->ensureEnabledColumn(); $this->ensureDriverOwnershipColumns(); } /** * 确保 enabled 字段存在 */ protected function ensureEnabledColumn(): void { try { $prefix = (string) config('database.connections.mysql.prefix', 'eb_'); $db = config('database.connections.mysql.database'); $result = \think\facade\Db::query( "SELECT COLUMN_NAME FROM information_schema.columns WHERE table_schema = ? AND table_name = ? AND column_name = 'enabled'", [$db, $prefix . 'module_install_log'] ); if (empty($result)) { \think\facade\Db::execute( "ALTER TABLE `{$prefix}module_install_log` ADD COLUMN `enabled` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0未启用 1已启用' AFTER `status`" ); } } catch (\Throwable $e) { // 忽略,可能已存在 } } /** 兼容已安装系统:为统一驱动字典补齐运行元数据和插件所有权。 */ protected function ensureDriverOwnershipColumns(): void { try { $prefix = (string) config('database.connections.mysql.prefix', 'eb_'); $db = (string) config('database.connections.mysql.database'); $table = $prefix . 'driver'; $rows = \think\facade\Db::query( 'SELECT COLUMN_NAME FROM information_schema.columns' . ' WHERE table_schema = ? AND table_name = ?' . " AND column_name IN ('namespace', 'owner_plugin_id')", [$db, $table] ); $columns = array_column($rows, 'COLUMN_NAME'); if (!in_array('namespace', $columns, true)) { \think\facade\Db::execute( "ALTER TABLE `{$table}` ADD COLUMN `namespace` varchar(160) NOT NULL DEFAULT '' COMMENT '驱动命名空间' AFTER `class_name`" ); } if (!in_array('owner_plugin_id', $columns, true)) { \think\facade\Db::execute( "ALTER TABLE `{$table}` ADD COLUMN `owner_plugin_id` varchar(96) NOT NULL DEFAULT '' COMMENT '所属驱动插件复合标识' AFTER `namespace`" ); } $indexes = \think\facade\Db::query( 'SELECT INDEX_NAME FROM information_schema.statistics' . ' WHERE table_schema = ? AND table_name = ? AND index_name = ?', [$db, $table, 'idx_owner_plugin_id'] ); if (empty($indexes)) { \think\facade\Db::execute( "ALTER TABLE `{$table}` ADD INDEX `idx_owner_plugin_id` (`owner_plugin_id`)" ); } } catch (\Throwable $e) { // 安装插件时会再给出明确的表结构错误,不在列表接口阶段中断后台。 } } /** * 上传并安装插件 ZIP 包 * * 接收 multipart/form-data,字段名 plugin_package * 验证 ZIP → 提取到 plugin.json 中 path 字段对应的目录 → 返回插件元数据 * 例:path="modules/distribution" → 解压到 backend/modules/distribution/ * * @return \think\response\Json */ public function upload() { $file = $this->request->file('plugin_package'); if (!$file) { return app('json')->fail('请上传插件安装包(ZIP 格式)'); } // 验证文件类型 $suffix = strtolower(pathinfo($file->getOriginalName(), PATHINFO_EXTENSION)); if ($suffix !== 'zip') { return app('json')->fail('仅支持 ZIP 格式的插件安装包'); } if ((int) $file->getSize() > 50 * 1024 * 1024) { return app('json')->fail('插件安装包不能超过 50MB'); } $targetDir = app()->getRootPath(); $tmpDir = ''; try { // 临时目录放在 runtime(Web 根外),避免解压物被 HTTP 直接访问执行 $tmpRoot = $targetDir . 'runtime' . DIRECTORY_SEPARATOR . 'plugin_upload' . DIRECTORY_SEPARATOR; if (!is_dir($tmpRoot) && !mkdir($tmpRoot, 0755, true) && !is_dir($tmpRoot)) { return app('json')->fail('无法创建插件上传临时目录'); } $tmpDir = $tmpRoot . '.plugin_tmp_' . uniqid('', true) . DIRECTORY_SEPARATOR; if (!mkdir($tmpDir, 0755, true) && !is_dir($tmpDir)) { return app('json')->fail('无法创建插件上传临时目录'); } $zipPath = $tmpDir . 'package.zip'; $file->move($tmpDir, 'package.zip'); SafeZip::extract($zipPath, $tmpDir, ['require_plugin_json' => true]); // 查找 plugin.json(可能在根目录或一级子目录) $pluginJson = $tmpDir . 'plugin.json'; if (!is_file($pluginJson)) { // 查找一级子目录中的 plugin.json $subDirs = glob($tmpDir . '*/plugin.json'); if (count($subDirs) !== 1) { $this->cleanTempDir($tmpDir); return app('json')->fail('ZIP 包必须且只能包含一个根级插件目录和 plugin.json'); } $pluginJson = $subDirs[0]; } $meta = json_decode(file_get_contents($pluginJson), true); if (empty($meta['path']) || empty($meta['type'])) { $this->cleanTempDir($tmpDir); return app('json')->fail('plugin.json 格式不完整,缺少 path 或 type 字段'); } // 根据 type 字段确定目标父目录 // type=product/product_type → modules/product/{name}/(商品域:产品类型) // type=activity/mode → modules/trade/{name}/(交易域:交易模式) // type=user → modules/user/{name}/ // type=channel → modules/channel/{name}/ // type=driver → 按 plugin.json 的 path 安装到 driver/{category}/{name}/ // (驱动发现服务只扫描 driver/pay|sms|upload|express|logistics| // invoice|email|bot|copyproduct|printer 目录, // 安装到 modules/drivers 将导致驱动无法被扫描发现) // 其他类型 → modules/{name}/ $typeDirMap = [ 'product' => 'modules' . DIRECTORY_SEPARATOR . 'product', 'product_type' => 'modules' . DIRECTORY_SEPARATOR . 'product', 'activity' => 'modules' . DIRECTORY_SEPARATOR . 'trade', 'mode' => 'modules' . DIRECTORY_SEPARATOR . 'trade', 'user' => 'modules' . DIRECTORY_SEPARATOR . 'user', 'channel' => 'modules' . DIRECTORY_SEPARATOR . 'channel', 'marketing' => 'modules' . DIRECTORY_SEPARATOR . 'marketing', ]; $type = $meta['type']; // 从 path 中提取插件目录名(最后一段) $pluginName = basename($meta['path']); if ($type === 'driver') { // 驱动插件:path 形如 driver/{category}/{name},必须安装到对应驱动目录 // 命名空间与 autoload(driver\ → driver/)及各 DriverServices 扫描路径保持一致 $driverPath = str_replace(['\\', '/'], DIRECTORY_SEPARATOR, trim((string)$meta['path'], '/')); $segments = explode(DIRECTORY_SEPARATOR, $driverPath); if (count($segments) !== 3 || $segments[0] !== 'driver' || in_array('..', $segments, true) || in_array('.', $segments, true)) { $this->cleanTempDir($tmpDir); return app('json')->fail('驱动插件 path 格式错误,应为 driver/{category}/{name}(如 driver/sms/yunxin)'); } // 驱动类别须有对应的发现服务,否则装完也不会被扫描到 $allowedCategories = ['pay', 'sms', 'upload', 'express', 'logistics', 'invoice', 'email', 'bot', 'copyproduct', 'printer']; if (!in_array($segments[1], $allowedCategories, true)) { $this->cleanTempDir($tmpDir); return app('json')->fail('不支持的驱动类别「' . $segments[1] . '」,支持:' . implode('/', $allowedCategories)); } $relativePath = $driverPath; } elseif (isset($typeDirMap[$type])) { $relativePath = $typeDirMap[$type] . DIRECTORY_SEPARATOR . $pluginName; } else { $relativePath = 'modules' . DIRECTORY_SEPARATOR . $pluginName; } $destDir = $targetDir . $relativePath . DIRECTORY_SEPARATOR; if (is_dir($destDir)) { // An existing directory is an upgrade candidate. The package // must identify exactly the same plugin and carry a higher version. $currentManifest = PluginManifest::fromFile($destDir . 'plugin.json', $targetDir); $newPath = trim(str_replace('\\', '/', (string) $meta['path']), '/'); if ($newPath !== trim(str_replace('\\', '/', $relativePath), '/')) { $this->cleanTempDir($tmpDir); return app('json')->fail('插件 path 与安装目标不一致'); } if ((string) ($meta['key'] ?? '') !== $currentManifest->identity->legacyKey || (string) ($meta['type'] ?? '') !== $currentManifest->type) { $this->cleanTempDir($tmpDir); return app('json')->fail('升级包不是当前插件的同一版本线'); } $newVersion = (string) ($meta['version'] ?? ''); if ($newVersion === '' || !version_compare($newVersion, $currentManifest->version, '>')) { $this->cleanTempDir($tmpDir); return app('json')->fail("新版本 {$newVersion} 必须高于当前版本 {$currentManifest->version}"); } $sourceDir = dirname($pluginJson); $migration = $this->services->runUpgradeMigrations( $currentManifest->identity->canonicalId(), $sourceDir . DIRECTORY_SEPARATOR . 'upgrades', $newVersion ); $backupRoot = $targetDir . 'backup' . DIRECTORY_SEPARATOR . '.plugin_backup' . DIRECTORY_SEPARATOR; $backupDir = $backupRoot . basename($relativePath) . '_' . date('YmdHis') . DIRECTORY_SEPARATOR; if (!is_dir($backupRoot) && !mkdir($backupRoot, 0755, true) && !is_dir($backupRoot)) { throw new \RuntimeException('无法创建插件备份目录'); } if (!rename($destDir, $backupDir)) { throw new \RuntimeException('无法备份旧插件文件'); } try { $this->copyDir($sourceDir, $destDir); PluginManifest::fromFile($destDir . 'plugin.json', $targetDir); } catch (\Throwable $e) { $this->cleanTempDir($destDir); @rename($backupDir, $destDir); throw $e; } $this->services->updateInstalledVersion($currentManifest->identity->canonicalId(), $newVersion); $this->cleanTempDir($tmpDir); PluginManager::resetCache(); \crmeb\plugin\ModuleStateChecker::reload(); return app('json')->success('插件升级成功', [ 'action' => 'upgrade', 'id' => $currentManifest->identity->canonicalId(), 'key' => $meta['key'], 'name' => $meta['name'] ?? $currentManifest->name, 'from_version' => $migration['from_version'], 'version' => $newVersion, 'steps' => $migration['steps'], ]); } // 从临时目录复制到目标目录 $sourceDir = dirname($pluginJson); $this->copyDir($sourceDir, $destDir); // 复制完成后使用统一清单模型做目录、字段、驱动信息和复合身份校验。 try { $manifest = PluginManifest::fromFile($destDir . 'plugin.json', $targetDir); } catch (\Throwable $e) { $this->cleanTempDir($destDir); throw $e; } //清理临时目录 $this->cleanTempDir($tmpDir); // 重置 ModuleInstaller 缓存,使新插件可被发现 PluginManager::resetCache(); return app('json')->success('插件上传成功', [ 'id' => $manifest->identity->canonicalId(), 'key' => $meta['key'] ?? $relativePath, 'name' => $meta['name'] ?? $meta['key'] ?? $pluginName, 'type' => $meta['type'], 'version' => $meta['version'] ?? '1.0.0', 'description' => $meta['description'] ?? '', 'author' => $meta['author'] ?? '', ]); } catch (\Throwable $e) { $this->cleanTempDir($tmpDir ?? ''); return app('json')->fail('上传失败: ' . $e->getMessage()); } } /** * 递归复制目录 */ protected function copyDir(string $src, string $dst): void { if (!is_dir($dst)) { mkdir($dst, 0755, true); } $items = scandir($src); foreach ($items as $item) { if ($item === '.' || $item === '..') { continue; } $s = $src . DIRECTORY_SEPARATOR . $item; $d = $dst . DIRECTORY_SEPARATOR . $item; if (is_dir($s)) { $this->copyDir($s, $d); } else { copy($s, $d); } } } /** * 清理临时目录 * * 跳过 __MACOSX 等 macOS ZIP 产物,处理过程中可能存在的断链等情况。 */ protected function cleanTempDir(string $dir): void { if (!is_dir($dir)) { return; } $items = @scandir($dir); if ($items === false) { return; } foreach (array_diff($items, ['.', '..']) as $item) { // 跳过 macOS 特殊目录(ZIP 产物) if ($item === '__MACOSX' || str_starts_with($item, '._')) { $this->removeForce($dir . DIRECTORY_SEPARATOR . $item); continue; } $path = $dir . DIRECTORY_SEPARATOR . $item; if (is_dir($path)) { $this->cleanTempDir($path); @rmdir($path); } else { @unlink($path); } } @rmdir($dir); } /** * 强制删除文件或目录(处理 macOS 特殊文件/断链) */ protected function removeForce(string $path): void { if (!file_exists($path) && !is_link($path)) { return; } if (is_dir($path) && !is_link($path)) { $this->cleanTempDir($path); @rmdir($path); } else { @unlink($path); } } /** * 获取所有插件列表及状态 * @return \think\response\Json */ public function list() { $type = $this->request->param('type', ''); if ($type) { $list = $this->services->getStatusListByType($type); } else { $list = $this->services->getStatusList(); } return app('json')->success($list); } /** * 安装插件 * @return \think\response\Json */ public function install() { $pluginKey = $this->request->param('plugin_key', ''); if (!$pluginKey) { return app('json')->fail('请传入插件标识 plugin_key'); } try { $result = $this->services->install($pluginKey); return app('json')->success('安装成功', $result); } catch (\Throwable $e) { return app('json')->fail('安装失败: ' . $e->getMessage()); } } /** * 卸载插件 * @return \think\response\Json */ public function uninstall() { $pluginKey = $this->request->param('plugin_key', ''); if (!$pluginKey) { return app('json')->fail('请传入插件标识 plugin_key'); } try { $result = $this->services->uninstall($pluginKey); return app('json')->success('卸载成功', $result); } catch (\Throwable $e) { return app('json')->fail('卸载失败: ' . $e->getMessage()); } } /** * 启用插件 * @return \think\response\Json */ public function enable() { $pluginKey = $this->request->param('plugin_key', ''); if (!$pluginKey) { return app('json')->fail('请传入插件标识 plugin_key'); } try { $result = $this->services->enable($pluginKey); PluginManager::resetCache(); return app('json')->success('启用成功', $result); } catch (\Throwable $e) { return app('json')->fail('启用失败: ' . $e->getMessage()); } } /** * 禁用插件 * @return \think\response\Json */ public function disable() { $pluginKey = $this->request->param('plugin_key', ''); if (!$pluginKey) { return app('json')->fail('请传入插件标识 plugin_key'); } try { $result = $this->services->disable($pluginKey); PluginManager::resetCache(); return app('json')->success('禁用成功', $result); } catch (\Throwable $e) { return app('json')->fail('禁用失败: ' . $e->getMessage()); } } /** * 刷新插件消息模板(TEMPORARY-PLUGIN-NOTICE-REFRESH:临时功能,后续移除)。 * 以插件 Installer::getNoticeDefinitions 为准,覆盖式更新该插件的通知/渠道模板。 * @return \think\response\Json */ public function refreshNotices() { $pluginKey = $this->request->param('plugin_key', ''); if (!$pluginKey) { return app('json')->fail('请传入插件标识 plugin_key'); } try { $result = $this->services->refreshNotices($pluginKey); return app('json')->success('刷新成功', $result); } catch (\Throwable $e) { return app('json')->fail('刷新失败: ' . $e->getMessage()); } } }