# 插件前端自动化部署方案 ## 📋 文档信息 - **文档版本**: v1.0 - **创建日期**: 2024-11-04 - **作者**: AI Assistant - **项目**: SixShop 电商系统 - **适用范围**: 所有带前端代码的插件 ## 🎯 方案目标 ### 当前问题 1. **手动操作繁琐** - 安装插件后需要手动复制前端代码 - 需要手动运行 `npm run build` 编译 - 卸载插件后需要手动删除前端代码 2. **容易出错** - 忘记复制前端代码导致功能不可用 - 忘记删除前端代码导致冗余文件 - 手动操作可能导致文件遗漏或错误 3. **开发效率低** - 每次测试都要重复手动操作 - 影响开发和测试效率 ### 期望目标 1. **完全自动化** - 安装插件时自动部署前端代码 - 自动触发编译(可选) - 卸载插件时自动清理前端代码 2. **安全可靠** - 完善的错误处理 - 操作日志记录 - 支持回滚机制 3. **用户友好** - 实时进度反馈 - 清晰的状态提示 - 友好的错误信息 ## 🏗️ 架构设计 ### 用户操作流程 #### 安装插件 ``` 用户访问插件管理页面 http://localhost:3000/plugins/index ↓ 点击插件的"安装"按钮 ↓ 前端发送 POST 请求 POST https://sixshop.ddev.site/admin/system/extension/{plugin-id}/install ↓ 后端处理安装 ├─ 1. 检查扩展状态 ├─ 2. 运行数据库迁移(创建表) ├─ 3. 调用 Extension::install() ├─ 4. 保存默认配置 └─ 5. 更新扩展状态为"已安装" ↓ 触发 after_install_extension 事件 ↓ ✨ 自动部署前端代码 FrontendDeployService::deploy() ├─ 1. 检查插件是否有前端代码 ├─ 2. 复制前端代码到 frontend/admin/src/views/ ├─ 3. 记录部署日志 ├─ 4. 推送部署状态(可选) └─ 5. 触发 npm 编译(可选) ↓ 返回 200 OK ↓ 前端显示"安装成功" 用户可以立即使用插件功能 ``` #### 卸载插件 ``` 用户访问插件管理页面 http://localhost:3000/plugins/index ↓ 点击插件的"卸载"按钮 ↓ 前端发送 POST 请求 POST https://sixshop.ddev.site/admin/system/extension/{plugin-id}/uninstall ↓ 触发 before_uninstall_extension 事件 ↓ ✨ 自动清理前端代码 FrontendDeployService::remove() ├─ 1. 检查前端代码是否存在 ├─ 2. 删除 frontend/admin/src/views/{plugin-id}/ ├─ 3. 记录删除日志 ├─ 4. 推送删除状态(可选) └─ 5. 触发 npm 编译(可选) ↓ 后端处理卸载 ├─ 1. 运行数据库回滚(删除表) ├─ 2. 调用 Extension::uninstall() ├─ 3. 删除配置数据 └─ 4. 更新扩展状态为"未安装" ↓ 返回 200 OK ↓ 前端显示"卸载成功" 插件完全清理,不留痕迹 ``` ### 技术流程 ``` ExtensionController::install() ↓ ExtensionManager::install() ├─ 检查扩展状态 ├─ 运行数据库迁移 ├─ 调用 Extension::install() └─ 更新扩展状态 ↓ 触发 after_install_extension 事件 ↓ FrontendDeployService::deploy() ├─ 检查是否有前端代码 ├─ 复制前端代码到 views 目录 ├─ 记录部署日志 ├─ 推送部署状态(可选) └─ 触发 npm 编译(可选) ↓ 返回安装成功 ``` ### 关键特性 #### 1. 一键完成 **安装**: - ✅ 用户只需点击一次"安装"按钮 - ✅ 后端自动完成前端+后端安装 - ✅ 前端代码自动部署到正确位置 - ✅ 数据库表自动创建 - ✅ 默认配置自动保存 - ✅ 用户无需任何额外操作 **卸载**: - ✅ 用户只需点击一次"卸载"按钮 - ✅ 前端代码自动删除 - ✅ 数据库表自动清理 - ✅ 配置数据自动删除 - ✅ 完全卸载,不留痕迹 #### 2. 完全自动化 **对用户**: - 无需手动复制文件 - 无需手动运行命令 - 无需手动删除文件 - 安装即可用,卸载即清理 **对开发者**: - 无需修改现有代码 - 只需添加 frontend 目录 - 系统自动处理一切 **对系统**: - 不侵入现有流程 - 通过事件监听实现 - 解耦合,易维护 #### 3. 智能检测 **路径检测**: - 自动检测插件位置(runtime/extension 或 vendor) - 自动检测前端代码是否存在 - 无前端代码自动跳过 **状态管理**: - 实时记录部署状态 - 完整的操作日志 - 可追溯的部署历史 #### 4. 安全可靠 **安全性**: - 命令执行安全检查 - 路径安全验证 - 权限检查机制 **可靠性**: - 完善的错误处理 - 失败自动回滚 - 不影响插件安装 ### 核心组件 #### 1. FrontendDeployService(前端部署服务) **职责**: - 检查插件是否有前端代码 - 复制/删除前端代码 - 触发编译任务 - 状态管理和日志记录 **位置**: ``` backend/runtime/extension/system/src/Service/FrontendDeployService.php ``` #### 2. BuildFrontendJob(编译队列任务) **职责**: - 异步执行 npm 编译 - 实时推送编译进度 - 错误处理和日志记录 **位置**: ``` backend/runtime/extension/system/src/Job/BuildFrontendJob.php ``` #### 3. 事件监听器 **监听事件**: - `after_install_extension` - 安装后部署前端 - `before_uninstall_extension` - 卸载前清理前端 **位置**: ``` backend/runtime/extension/system/src/Extension.php ``` ## 📁 文件结构 ### 插件安装位置 插件可能安装在两个位置: #### 1. 本地开发插件(runtime/extension) ``` backend/runtime/extension/business-card/ ├── frontend/ ✨ 新增:前端代码目录 │ └── business-card/ # 使用插件名称作为子目录 │ ├── api/ # API 接口 │ │ └── index.js │ ├── components/ # 组件 │ │ ├── CardPanel.vue │ │ ├── CardForm.vue │ │ └── ... │ ├── index.vue # 主页面 │ └── router.js # 路由配置 ├── src/ # 后端代码 ├── database/ # 数据库迁移 └── ... ``` #### 2. Composer 安装插件(vendor) ``` backend/vendor/sixdec/business-card/ ├── frontend/ ✨ 新增:前端代码目录 │ └── business-card/ # 使用插件名称作为子目录 │ ├── api/ │ ├── components/ │ ├── index.vue │ └── router.js ├── src/ ├── database/ └── ... ``` **说明**: - `runtime/extension/` - 本地开发的插件 - `vendor/six-shop/` - Six-Shop 官方插件 - `vendor/sixdec/` - SixDec 第三方插件 - 系统会自动检测插件的实际位置 - **前端代码目录结构**:`frontend/{plugin-name}/` - 使用插件名称作为子目录,方便直接复制 ### 部署后的位置 ``` frontend/admin/src/views/ ├── {plugin-name}/ ✨ 自动复制到这里 │ ├── api/ │ ├── components/ │ ├── index.vue │ └── router.js ├── other-plugin/ └── ... ``` ### 部署记录 ``` backend/runtime/ ├── frontend_deploy/ ✨ 新增:部署记录目录 │ ├── deployed.json # 已部署的插件列表 │ └── logs/ # 部署日志 │ ├── business-card.log │ └── ... └── ... ``` ## 🔧 核心功能实现 ### 1. 插件路径检测 ```php /** * 获取插件的实际路径 * * 插件可能在两个位置: * 1. runtime/extension/{plugin-name} - 本地开发 * 2. vendor/{vendor}/{plugin-name} - Composer 安装 */ private function getPluginPath(string $extensionId): string { // 方法 1: 使用 Helper::extension_path() // 这个方法已经处理了路径检测 $path = Helper::extension_path($extensionId); if (is_dir($path)) { return $path; } // 方法 2: 从 CoreService 获取 Composer 映射 if (isset(CoreService::$extensionComposerMap[$extensionId])) { $composerInfo = CoreService::$extensionComposerMap[$extensionId]; $vendorPath = base_path('vendor/' . $composerInfo['name']); if (is_dir($vendorPath)) { return $vendorPath; } } // 方法 3: 手动检测常见路径 $possiblePaths = [ base_path('runtime/extension/' . $extensionId), base_path('vendor/six-shop/' . $extensionId), base_path('vendor/sixdec/' . $extensionId), ]; foreach ($possiblePaths as $path) { if (is_dir($path)) { return $path; } } throw new RuntimeException("找不到插件路径:{$extensionId}"); } /** * 获取插件前端代码路径 */ private function getPluginFrontendPath(string $extensionId): string { $pluginPath = $this->getPluginPath($extensionId); return $pluginPath . '/frontend'; } ``` ### 2. 前端代码检测 ```php /** * 检查插件是否有前端代码 */ private function hasFrontendCode(string $extensionId): bool { try { $frontendPath = $this->getPluginFrontendPath($extensionId); } catch (\Exception $e) { Log::warning("获取插件路径失败:{$extensionId},错误:" . $e->getMessage()); return false; } // 检查目录是否存在 if (!is_dir($frontendPath)) { return false; } // 检查必需文件是否存在 $requiredFiles = ['index.vue', 'router.js']; foreach ($requiredFiles as $file) { if (!file_exists($frontendPath . '/' . $file)) { Log::warning("插件 {$extensionId} 缺少必需文件:{$file}"); return false; } } return true; } ``` ### 3. 文件复制 ```php /** * 复制前端代码 * * 源目录结构:{plugin-path}/frontend/business-card/ * 目标目录:frontend/admin/src/views/business-card/ * * 直接复制 frontend/ 目录下的内容到 views/ 目录 */ private function copyFrontendCode(string $extensionId): void { // 1. 获取源目录:{plugin-path}/frontend/ $sourcePath = $this->getPluginPath($extensionId) . '/frontend'; if (!is_dir($sourcePath)) { throw new RuntimeException("前端代码目录不存在:{$sourcePath}"); } // 2. 获取目标目录:frontend/admin/src/views/ $targetPath = base_path('frontend/admin/src/views'); if (!is_dir($targetPath)) { throw new RuntimeException("目标目录不存在:{$targetPath}"); } // 3. 直接复制 frontend/ 目录下的所有内容 // 例如:frontend/business-card/ -> views/business-card/ $this->copyDirectory($sourcePath, $targetPath); Log::info("前端代码复制成功:{$extensionId}"); } /** * 递归复制目录 */ private function copyDirectory(string $source, string $target): void { // 递归复制文件 $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $item) { // 计算目标路径 $subPath = $iterator->getSubPathName(); $targetPath = $target . DIRECTORY_SEPARATOR . $subPath; if ($item->isDir()) { // 创建目录 if (!is_dir($targetPath)) { mkdir($targetPath, 0755, true); } } else { // 复制文件 $targetDir = dirname($targetPath); if (!is_dir($targetDir)) { mkdir($targetDir, 0755, true); } copy($item, $targetPath); } } } ``` **优势**: - ✅ 无需重命名,直接复制 - ✅ 保持目录结构完整 - ✅ 支持多层嵌套目录 - ✅ 代码简单,易于维护 **示例**: ``` 源:backend/runtime/extension/business-card/frontend/business-card/ 目标:frontend/admin/src/views/business-card/ 复制结果: frontend/admin/src/views/ └── business-card/ ├── api/ ├── components/ ├── index.vue └── router.js ``` ### 4. 文件删除 ```php /** * 删除前端代码 */ private function removeDirectory(string $path): void { if (!is_dir($path)) { return; } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($iterator as $item) { if ($item->isDir()) { rmdir($item); } else { unlink($item); } } rmdir($path); } ``` ### 5. 编译触发 ```php /** * 触发前端编译 */ private function triggerBuild(string $extensionId): void { // 开发环境不编译 if (app()->isDebug()) { Log::info("开发环境,跳过编译"); return; } // 检查 exec 是否可用 if (!$this->isExecAvailable()) { Log::warning("exec 函数被禁用,无法自动编译"); return; } // 使用队列异步编译 Queue::push(new BuildFrontendJob($extensionId)); } ``` ## 🔒 安全性设计 ### 1. 命令执行安全 ```php /** * 检查 exec 是否可用 */ private function isExecAvailable(): bool { $disabled = explode(',', ini_get('disable_functions')); return !in_array('exec', $disabled); } /** * 安全执行命令 */ private function safeExec(string $command): array { // 1. 检查函数可用性 if (!$this->isExecAvailable()) { throw new RuntimeException('exec 函数被禁用'); } // 2. 转义命令 $command = escapeshellcmd($command); // 3. 执行并获取输出 exec($command, $output, $returnCode); return [ 'output' => $output, 'code' => $returnCode ]; } ``` ### 2. 路径安全 ```php /** * 验证路径安全性 */ private function validatePath(string $path): bool { // 1. 检查路径是否包含危险字符 if (preg_match('/\.\./', $path)) { return false; } // 2. 检查路径是否在允许的范围内 $allowedPaths = [ base_path('frontend/admin/src/views'), Helper::extension_path() ]; $realPath = realpath($path); foreach ($allowedPaths as $allowedPath) { if (strpos($realPath, realpath($allowedPath)) === 0) { return true; } } return false; } ``` ### 3. 权限检查 ```php /** * 检查文件系统权限 */ private function checkPermissions(): array { $errors = []; // 检查源目录可读 $sourcePath = Helper::extension_path(); if (!is_readable($sourcePath)) { $errors[] = "源目录不可读:{$sourcePath}"; } // 检查目标目录可写 $targetPath = base_path('frontend/admin/src/views'); if (!is_writable($targetPath)) { $errors[] = "目标目录不可写:{$targetPath}"; } return $errors; } ``` ## 📊 进度反馈方案 ### 方案对比 | 方案 | 实时性 | 实现难度 | 服务器要求 | 推荐度 | |------|--------|----------|-----------|--------| | WebSocket | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 需要 WebSocket | ⭐⭐⭐⭐ | | 轮询 | ⭐⭐⭐ | ⭐⭐ | 无特殊要求 | ⭐⭐⭐⭐⭐ | | 日志文件 | ⭐⭐ | ⭐ | 无特殊要求 | ⭐⭐⭐ | ### 推荐方案:轮询 + 缓存 #### 后端实现 ```php /** * 设置部署状态 */ private function setDeployStatus(string $extensionId, string $status, string $message, int $progress = 0): void { cache('frontend_deploy_' . $extensionId, [ 'status' => $status, // idle, copying, building, success, error 'message' => $message, 'progress' => $progress, // 0-100 'updated_at' => time() ], 3600); // 缓存1小时 } /** * 获取部署状态 */ public function getDeployStatus(string $extensionId): array { return cache('frontend_deploy_' . $extensionId) ?: [ 'status' => 'idle', 'message' => '', 'progress' => 0, 'updated_at' => 0 ]; } ``` #### 前端实现 ```javascript // 安装插件后开始轮询 async function installExtension(extensionId) { // 1. 调用安装接口 await api.post(`/extension/${extensionId}/install`) // 2. 开始轮询部署状态 startPolling(extensionId) } // 轮询部署状态 function startPolling(extensionId) { const timer = setInterval(async () => { const status = await api.get(`/extension/${extensionId}/deploy-status`) // 更新进度条 updateProgress(status.progress, status.message) // 检查是否完成 if (status.status === 'success') { ElMessage.success('安装完成!') clearInterval(timer) } else if (status.status === 'error') { ElMessage.error('安装失败:' + status.message) clearInterval(timer) } }, 2000) // 每2秒查询一次 } ``` ## 🔄 兼容性处理 ### 1. 无前端代码的插件 ```php public function deploy(string $extensionId): bool { // 检查是否有前端代码 if (!$this->hasFrontendCode($extensionId)) { Log::info("插件 {$extensionId} 没有前端代码,跳过部署"); return true; // 返回 true 表示成功(跳过) } // 继续部署流程... } ``` ### 2. exec 函数被禁用 ```php private function triggerBuild(string $extensionId): void { if (!$this->isExecAvailable()) { // 记录警告 Log::warning("exec 函数被禁用,无法自动编译"); // 写入标记文件,提示用户手动编译 file_put_contents( runtime_path('need_build.txt'), "请手动执行:cd frontend/admin && npm run build\n", FILE_APPEND ); return; } // 正常编译流程... } ``` ### 3. 编译失败处理 ```php class BuildFrontendJob { public function handle(): void { try { // 执行编译 $result = $this->safeExec('cd frontend/admin && npm run build'); if ($result['code'] !== 0) { throw new RuntimeException('编译失败:' . implode("\n", $result['output'])); } // 更新状态为成功 $this->setStatus('success', '编译完成', 100); } catch (\Exception $e) { // 记录错误 Log::error('前端编译失败:' . $e->getMessage()); // 更新状态为失败 $this->setStatus('error', $e->getMessage(), 0); // 不抛出异常,避免影响其他任务 } } } ``` ## 📝 部署记录 ### 记录格式 ```json { "deployed": [ { "extension_id": "business-card", "deployed_at": "2024-11-04 10:30:00", "source_path": "/path/to/extension/business-card/frontend", "target_path": "/path/to/admin/views/business-card", "files_count": 15, "status": "success" } ] } ``` ### 记录管理 ```php /** * 记录部署信息 */ private function logDeploy(string $extensionId, array $info): void { $logFile = runtime_path('frontend_deploy/deployed.json'); // 读取现有记录 $deployed = []; if (file_exists($logFile)) { $deployed = json_decode(file_get_contents($logFile), true); } // 添加新记录 $deployed['deployed'][] = array_merge([ 'extension_id' => $extensionId, 'deployed_at' => date('Y-m-d H:i:s'), ], $info); // 写入文件 file_put_contents($logFile, json_encode($deployed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } ``` ## 🧪 测试方案 ### 测试场景 1. **正常安装** - 有前端代码的插件 - 自动复制和编译 2. **无前端代码** - 没有 frontend 目录的插件 - 跳过前端部署 3. **重复安装** - 已安装的插件 - 覆盖现有文件 4. **卸载清理** - 删除前端代码 - 清理部署记录 5. **异常处理** - 权限不足 - exec 被禁用 - 编译失败 ### 测试步骤 ```bash # 1. 测试安装 POST /admin/system/extension/business_card/install # 2. 检查文件是否复制 ls frontend/admin/src/views/business-card # 3. 检查部署状态 GET /admin/system/extension/business_card/deploy-status # 4. 测试卸载 POST /admin/system/extension/business_card/uninstall # 5. 检查文件是否删除 ls frontend/admin/src/views/business-card # 应该不存在 ``` ## 📈 性能优化 ### 1. 异步处理 - 文件复制:同步执行(快速) - npm 编译:异步队列(慢速) - 状态推送:异步(不阻塞) ### 2. 缓存策略 - 部署状态:缓存 1 小时 - 部署记录:持久化存储 - 编译日志:定期清理 ### 3. 队列优化 ```php // 使用延迟队列,避免并发编译 Queue::later(5, new BuildFrontendJob($extensionId)); // 使用队列优先级 Queue::push(new BuildFrontendJob($extensionId), '', 'high'); ``` ## 🚀 实施计划 ### 阶段 1: 核心功能(1-2天) - [ ] 创建 FrontendDeployService - [ ] 实现文件复制/删除 - [ ] 注册事件监听 - [ ] 基础测试 ### 阶段 2: 编译功能(1天) - [ ] 创建 BuildFrontendJob - [ ] 实现安全的命令执行 - [ ] 错误处理和日志 ### 阶段 3: 进度反馈(1天) - [ ] 实现状态管理 - [ ] 添加 API 接口 - [ ] 前端轮询实现 ### 阶段 4: 完善和测试(1天) - [ ] 完整测试所有场景 - [ ] 优化性能 - [ ] 编写文档 ## 📚 使用文档 ### 插件开发者 #### 1. 创建前端代码目录 ```bash cd backend/runtime/extension/your-plugin mkdir frontend ``` #### 2. 组织前端代码 ``` frontend/ ├── api/ │ └── index.js ├── components/ │ └── YourComponent.vue ├── index.vue └── router.js ``` #### 3. 测试安装 ```bash # 安装插件 POST /admin/system/extension/your-plugin/install # 检查前端代码 ls frontend/admin/src/views/your-plugin ``` ### 系统管理员 #### 1. 检查权限 ```bash # 检查目录权限 ls -la frontend/admin/src/views ls -la backend/runtime/extension ``` #### 2. 查看部署日志 ```bash # 查看部署记录 cat backend/runtime/frontend_deploy/deployed.json # 查看编译日志 cat backend/runtime/frontend_deploy/logs/your-plugin.log ``` #### 3. 手动编译 ```bash # 如果自动编译失败,可以手动编译 cd frontend/admin npm run build ``` ## ❓ 常见问题 ### Q1: exec 函数被禁用怎么办? **A**: 系统会自动检测并跳过编译,生成提示文件 `runtime/need_build.txt`,管理员可以手动执行编译。 ### Q2: 编译很慢怎么办? **A**: 编译是异步执行的,不会阻塞安装流程。可以通过轮询查看编译进度。 ### Q3: 安装失败如何回滚? **A**: 系统会自动删除已复制的文件,不会留下垃圾文件。 ### Q4: 开发时需要每次编译吗? **A**: 不需要。开发环境会跳过编译,直接使用 Vite 热更新。 ### Q5: 如何查看部署状态? **A**: 调用 API:`GET /admin/system/extension/{id}/deploy-status` ## 📞 技术支持 如有问题,请联系: - 邮箱:support@sixdec.com - 文档:查看本文档 - 日志:`backend/runtime/frontend_deploy/logs/` --- **文档结束**