zipPath = $zipPath; $this->targetDir = rtrim($targetDir, DIRECTORY_SEPARATOR); $this->dryRun = $options['dryRun'] ?? false; $this->verbose = $options['verbose'] ?? false; $this->backup = $options['backup'] ?? false; $this->validatePaths(); } /** * 验证路径有效性 */ private function validatePaths(): void { if (!file_exists($this->zipPath)) { throw new InvalidArgumentException("ZIP文件不存在: {$this->zipPath}"); } if (!is_readable($this->zipPath)) { throw new InvalidArgumentException("ZIP文件不可读: {$this->zipPath}"); } if (!is_dir($this->targetDir)) { throw new InvalidArgumentException("目标目录不存在: {$this->targetDir}"); } if (!is_writable($this->targetDir)) { throw new InvalidArgumentException("目标目录不可写: {$this->targetDir}"); } } /** * 执行部署 */ public function deploy(): array { $this->logMessage('INFO', '开始自动部署流程'); $this->logMessage('INFO', "ZIP文件: {$this->zipPath}"); $this->logMessage('INFO', "目标目录: {$this->targetDir}"); try { // 打开ZIP文件 $zip = new ZipArchive(); $openResult = $zip->open($this->zipPath); if ($openResult !== true) { throw new RuntimeException("无法打开ZIP文件,错误代码: {$openResult}"); } $this->logMessage('INFO', 'ZIP文件打开成功'); // 分析ZIP文件结构 $zipFiles = $this->analyzeZipStructure($zip); $this->logMessage('INFO', "ZIP包中包含 {$zipFiles['fileCount']} 个文件"); // 基于ZIP文件结构进行部署,不再预先分析目标目录 $result = $this->compareAndDeploy($zip, $zipFiles); // 关闭ZIP文件 $zip->close(); $this->logMessage('SUCCESS', '部署完成'); return $result; } catch (Exception $e) { $this->logMessage('ERROR', "部署失败: " . $e->getMessage()); throw $e; } } /** * 分析ZIP文件结构 */ private function analyzeZipStructure(ZipArchive $zip): array { $files = []; $fileCount = 0; $totalSize = 0; for ($i = 0; $i < $zip->numFiles; $i++) { $fileInfo = $zip->statIndex($i); if ($fileInfo === false) { continue; } $fileName = $fileInfo['name']; // 跳过目录条目 if (substr($fileName, -1) === '/') { continue; } $files[$fileName] = [ 'size' => $fileInfo['size'], 'compressed_size' => $fileInfo['comp_size'], 'modified' => $fileInfo['mtime'], 'crc32' => $fileInfo['crc'] ]; $fileCount++; $totalSize += $fileInfo['size']; } return [ 'files' => $files, 'fileCount' => $fileCount, 'totalSize' => $totalSize ]; } /** * 获取单个文件信息 */ private function getFileInfo(string $filePath): ?array { if (!file_exists($filePath)) { return null; } return [ 'size' => filesize($filePath), 'modified' => filemtime($filePath), 'permissions' => fileperms($filePath) ]; } /** * 比较差异并部署文件 */ private function compareAndDeploy(ZipArchive $zip, array $zipFiles): array { $results = [ 'added' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [] ]; foreach ($zipFiles['files'] as $filePath => $zipFileInfo) { $targetFilePath = $this->targetDir . DIRECTORY_SEPARATOR . $filePath; try { // 直接检查文件是否存在,不再依赖预先分析的目标目录结构 if (!file_exists($targetFilePath)) { // 新文件 - 需要创建目录并复制文件 $result = $this->deployNewFile($zip, $filePath, $targetFilePath); $results['added']++; $results['details'][] = [ 'file' => $filePath, 'action' => 'added', 'success' => true ]; } else { // 文件已存在 - 检查是否需要更新 $targetFileInfo = $this->getFileInfo($targetFilePath); $needsUpdate = $this->needsUpdate($zip, $filePath, $targetFileInfo); if ($needsUpdate) { $result = $this->deployUpdatedFile($zip, $filePath, $targetFilePath); $results['updated']++; $results['details'][] = [ 'file' => $filePath, 'action' => 'updated', 'success' => true ]; } else { $results['skipped']++; $results['details'][] = [ 'file' => $filePath, 'action' => 'skipped', 'success' => true ]; $this->logMessage('INFO', "文件无需更新: {$filePath}"); } } } catch (Exception $e) { $results['errors']++; $results['details'][] = [ 'file' => $filePath, 'action' => 'error', 'success' => false, 'error' => $e->getMessage() ]; $this->logMessage('ERROR', "处理文件失败 {$filePath}: " . $e->getMessage()); } } return $results; } /** * 部署新文件 */ private function deployNewFile(ZipArchive $zip, string $filePath, string $targetFilePath): bool { $this->logMessage('INFO', "添加新文件: {$filePath}"); if ($this->dryRun) { $this->logMessage('DRY_RUN', "将创建文件: {$targetFilePath}"); return true; } // 创建目录(如果需要) $targetDir = dirname($targetFilePath); if (!is_dir($targetDir)) { if (!mkdir($targetDir, 0755, true)) { throw new RuntimeException("无法创建目录: {$targetDir}"); } $this->logMessage('INFO', "创建目录: {$targetDir}"); } // 从ZIP中提取文件 $fileContent = $zip->getFromName($filePath); if ($fileContent === false) { throw new RuntimeException("无法从ZIP中读取文件: {$filePath}"); } // 写入文件 if (file_put_contents($targetFilePath, $fileContent) === false) { throw new RuntimeException("无法写入文件: {$targetFilePath}"); } $this->logMessage('SUCCESS', "文件创建成功: {$filePath}"); return true; } /** * 部署更新文件 */ private function deployUpdatedFile(ZipArchive $zip, string $filePath, string $targetFilePath): bool { $this->logMessage('INFO', "更新文件: {$filePath}"); if ($this->dryRun) { $this->logMessage('DRY_RUN', "将更新文件: {$targetFilePath}"); return true; } // 备份原文件(可选) if ($this->backup && file_exists($targetFilePath)) { $backupPath = $targetFilePath . '.backup.' . date('YmdHis'); if (copy($targetFilePath, $backupPath)) { $this->logMessage('INFO', "文件已备份: {$backupPath}"); } } // 从ZIP中提取文件内容 $fileContent = $zip->getFromName($filePath); if ($fileContent === false) { throw new RuntimeException("无法从ZIP中读取文件: {$filePath}"); } // 写入文件 if (file_put_contents($targetFilePath, $fileContent) === false) { throw new RuntimeException("无法写入文件: {$targetFilePath}"); } $this->logMessage('SUCCESS', "文件更新成功: {$filePath}"); return true; } /** * 检查文件是否需要更新 */ private function needsUpdate(ZipArchive $zip, string $filePath, ?array $targetFileInfo): bool { // 如果目标文件信息不存在,返回false(这种情况实际上不会发生,因为前面已经检查了文件存在) if ($targetFileInfo === null) { return false; } $zipFileInfo = $zip->statName($filePath); if ($zipFileInfo === false) { return false; } // 比较文件大小 if ($zipFileInfo['size'] != $targetFileInfo['size']) { $this->logMessage('DEBUG', "文件大小不同: {$filePath} (ZIP: {$zipFileInfo['size']}, 目标: {$targetFileInfo['size']})"); return true; } // 比较修改时间(ZIP时间可能不准确,作为次要判断) if ($zipFileInfo['mtime'] > $targetFileInfo['modified']) { $this->logMessage('DEBUG', "ZIP文件较新: {$filePath}"); return true; } // 比较CRC32校验(最可靠的方法) $targetFilePath = $this->targetDir . DIRECTORY_SEPARATOR . $filePath; $targetCrc = hash_file('crc32b', $targetFilePath); $zipCrc = sprintf('%08x', $zipFileInfo['crc']); if (strtolower($targetCrc) !== strtolower($zipCrc)) { $this->logMessage('DEBUG', "文件内容不同: {$filePath}"); return true; } return false; } /** * 获取相对路径 */ private function getRelativePath(string $path, string $basePath): string { $basePath = rtrim($basePath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; return str_replace($basePath, '', $path); } /** * 记录日志 */ private function logMessage(string $level, string $message): void { $timestamp = date('Y-m-d H:i:s'); $logEntry = "[{$timestamp}] [{$level}] {$message}"; $this->log[] = $logEntry; if ($this->verbose || in_array($level, ['ERROR', 'SUCCESS'])) { echo $logEntry . PHP_EOL; } } /** * 获取部署日志 */ public function getLog(): array { return $this->log; } } // 命令行接口 class CommandLineInterface { private array $options; public function __construct(array $argv) { $this->options = $this->parseOptions($argv); } public function run(): void { try { $this->showBanner(); if ($this->options['help'] ?? false) { $this->showHelp(); exit(0); } // 验证参数 $zipPath = $this->options['zip'] ?? null; $targetDir = $this->options['target'] ?? null; if (!$zipPath || !$targetDir) { echo "错误: 必须指定 --zip 和 --target 参数\n"; $this->showHelp(); exit(1); } // 创建部署器实例 $deployer = new AutoDeployer($zipPath, $targetDir, [ 'dryRun' => $this->options['dry-run'] ?? false, 'verbose' => $this->options['verbose'] ?? false, 'backup' => $this->options['backup'] ?? false ]); // 执行部署 $result = $deployer->deploy(); // 显示结果摘要 $this->showSummary($result); } catch (Exception $e) { echo "错误: " . $e->getMessage() . PHP_EOL; exit(1); } } private function parseOptions(array $argv): array { $options = [ 'help' => false, 'verbose' => false, 'dry-run' => false ]; for ($i = 1; $i < count($argv); $i++) { $arg = $argv[$i]; switch ($arg) { case '--help': case '-h': $options['help'] = true; break; case '--verbose': case '-v': $options['verbose'] = true; break; case '--dry-run': case '-d': $options['dry-run'] = true; break; case '--backup': case '-b': $options['backup'] = true; break; case '--zip': $options['zip'] = $argv[++$i] ?? null; break; case '--target': $options['target'] = $argv[++$i] ?? null; break; default: if (substr($arg, 0, 1) === '-') { echo "警告: 未知选项 {$arg}\n"; } break; } } return $options; } private function showBanner(): void { echo "========================================\n"; echo " 文件自动部署工具 v1.0\n"; echo " PHP 7.4 兼容版本\n"; echo "========================================\n\n"; } private function showHelp(): void { echo "用法: php deploy.php [选项]\n\n"; echo "选项:\n"; echo " --zip ZIP文件路径 (必需)\n"; echo " --target 目标目录路径 (必需)\n"; echo " --dry-run, -d 试运行,不实际修改文件\n"; echo " --backup, -b 部署前备份目标文件或目录\n"; echo " --verbose, -v 显示详细输出\n"; echo " --help, -h 显示此帮助信息\n\n"; echo "示例:\n"; echo " php deploy.php --zip update.zip --target /var/www/html\n"; echo " php deploy.php --zip patch.zip --target ./app --dry-run\n"; echo " php deploy.php --zip release.zip --target /var/www --verbose\n"; } private function showSummary(array $result): void { echo "\n部署摘要:\n"; echo "========================================\n"; echo "新增文件: {$result['added']}\n"; echo "更新文件: {$result['updated']}\n"; echo "跳过文件: {$result['skipped']}\n"; echo "错误文件: {$result['errors']}\n"; echo "========================================\n"; if ($result['errors'] > 0) { echo "\n错误详情:\n"; foreach ($result['details'] as $detail) { if (!$detail['success']) { echo " - {$detail['file']}: {$detail['error']}\n"; } } } } } // 主程序入口 if (PHP_SAPI === 'cli') { $cli = new CommandLineInterface($argv); $cli->run(); } else { echo "此脚本只能在命令行模式下运行\n"; exit(1); } ?>