插件前端自动化部署方案.md 23 KB

插件前端自动化部署方案

📋 文档信息

  • 文档版本: 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. 插件路径检测

/**
 * 获取插件的实际路径
 * 
 * 插件可能在两个位置:
 * 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. 前端代码检测

/**
 * 检查插件是否有前端代码
 */
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. 文件复制

/**
 * 复制前端代码
 * 
 * 源目录结构:{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. 文件删除

/**
 * 删除前端代码
 */
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. 编译触发

/**
 * 触发前端编译
 */
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. 命令执行安全

/**
 * 检查 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. 路径安全

/**
 * 验证路径安全性
 */
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. 权限检查

/**
 * 检查文件系统权限
 */
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 ⭐⭐⭐⭐
轮询 ⭐⭐⭐ ⭐⭐ 无特殊要求 ⭐⭐⭐⭐⭐
日志文件 ⭐⭐ 无特殊要求 ⭐⭐⭐

推荐方案:轮询 + 缓存

后端实现

/**
 * 设置部署状态
 */
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
    ];
}

前端实现

// 安装插件后开始轮询
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. 无前端代码的插件

public function deploy(string $extensionId): bool
{
    // 检查是否有前端代码
    if (!$this->hasFrontendCode($extensionId)) {
        Log::info("插件 {$extensionId} 没有前端代码,跳过部署");
        return true; // 返回 true 表示成功(跳过)
    }
    
    // 继续部署流程...
}

2. exec 函数被禁用

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. 编译失败处理

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);
            
            // 不抛出异常,避免影响其他任务
        }
    }
}

📝 部署记录

记录格式

{
  "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"
    }
  ]
}

记录管理

/**
 * 记录部署信息
 */
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 被禁用
    • 编译失败

测试步骤

# 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. 队列优化

// 使用延迟队列,避免并发编译
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. 创建前端代码目录

cd backend/runtime/extension/your-plugin
mkdir frontend

2. 组织前端代码

frontend/
├── api/
│   └── index.js
├── components/
│   └── YourComponent.vue
├── index.vue
└── router.js

3. 测试安装

# 安装插件
POST /admin/system/extension/your-plugin/install

# 检查前端代码
ls frontend/admin/src/views/your-plugin

系统管理员

1. 检查权限

# 检查目录权限
ls -la frontend/admin/src/views
ls -la backend/runtime/extension

2. 查看部署日志

# 查看部署记录
cat backend/runtime/frontend_deploy/deployed.json

# 查看编译日志
cat backend/runtime/frontend_deploy/logs/your-plugin.log

3. 手动编译

# 如果自动编译失败,可以手动编译
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/

文档结束