open($zipPath) !== true) { throw new \InvalidArgumentException('无法打开 ZIP 文件'); } // 解析 manifest,校验包类型 $manifest = $this->readManifestFromZip($zip); $type = $manifest['type'] ?? 'plugin'; $name = $manifest['name'] ?? null; if (! in_array($type, ['plugin', 'theme'], true)) { throw new \InvalidArgumentException('未知的包类型:'.$type); } if ($type === 'plugin') { [$vendor, $pluginName] = array_pad(explode('.', (string) $name), 2, $name); $target = config('plugins.path').'/'.$vendor.'.'.$pluginName; } else { $target = config('themes.path').'/'.$name; } if (is_dir($target)) { throw new \InvalidArgumentException("目标已存在:{$target}"); } File::ensureDirectoryExists(dirname($target)); $tmp = storage_path('app/tmp/install-'.Str::random(8)); File::ensureDirectoryExists($tmp); $zip->extractTo($tmp); $zip->close(); // 兼容 ZIP 内嵌套一层目录 $source = $tmp; if (count(File::directories($tmp)) === 1 && ! File::exists($tmp.'/plugin.json') && ! File::exists($tmp.'/theme.json')) { $source = File::directories($tmp)[0]; } $manifestFile = $type === 'plugin' ? 'plugin.json' : 'theme.json'; if (! File::exists($source.'/'.$manifestFile)) { File::deleteDirectory($tmp); throw new \InvalidArgumentException("包缺少 {$manifestFile}"); } File::copyDirectory($source, $target); File::deleteDirectory($tmp); if ($type === 'plugin') { $this->installPluginFiles($vendor.'.'.$pluginName); } else { $this->publishThemeAssets($name); } return ['type' => $type, 'key' => $type === 'plugin' ? $vendor.'.'.$pluginName : $name]; } /** * 从远程市场安装(带 checksum 校验)。 */ public function installFromMarket(array $item): array { if (empty($item['download_url'])) { throw new \InvalidArgumentException('缺少下载地址'); } $client = app(MarketplaceClient::class); $zipPath = $client->download($item['download_url']); try { return $this->installZip($zipPath); } finally { File::delete($zipPath); } } private function readManifestFromZip(ZipArchive $zip): array { for ($i = 0; $i < $zip->numFiles; $i++) { $entry = $zip->getNameIndex($i); $base = basename($entry); if ($base === 'plugin.json' || $base === 'theme.json') { $content = $zip->getFromIndex($i); $manifest = json_decode($content, true); if (! $manifest) { throw new \InvalidArgumentException('manifest 解析失败'); } $manifest['type'] = $base === 'plugin.json' ? 'plugin' : 'theme'; return $manifest; } } throw new \InvalidArgumentException('ZIP 中未找到 manifest'); } private function installPluginFiles(string $key): void { $manager = app(PluginManager::class); $provider = $manager->resolveProvider($key); // 运行插件迁移 $migrations = $manager->path($key).'/database/migrations'; if (is_dir($migrations) && $provider && class_exists($provider)) { Artisan::call('migrate', ['--path' => 'plugins/'.str_replace('.', '/', $key).'/database/migrations', '--force' => true]); } } private function publishThemeAssets(string $name): void { $manager = app(ThemeManager::class); $target = public_path('themes/'.$name); if (is_dir($manager->path($name).'/assets')) { File::copyDirectory($manager->path($name).'/assets', $target); } } /** * 卸载插件(删除目录 + 记录)。 */ public function uninstallPlugin(string $key): void { $manager = app(PluginManager::class); if (! $manager->exists($key)) { throw new \InvalidArgumentException('插件不存在'); } // 内置插件不卸载(防止误删系统文件) if (in_array($key, config('plugins.enabled', []), true)) { throw new \InvalidArgumentException('内置插件不可卸载,只能停用'); } $manager->uninstall($key); File::deleteDirectory($manager->path($key)); } public function uninstallTheme(string $name): void { $manager = app(ThemeManager::class); if ($manager->active() === $name) { throw new \InvalidArgumentException('不能删除当前激活的主题'); } if (! $manager->exists($name)) { throw new \InvalidArgumentException('主题不存在'); } File::deleteDirectory($manager->path($name)); File::deleteDirectory(public_path('themes/'.$name)); } }