rootPath = rtrim(App::getRootPath(), "\\/ \t\n\r\0\x0B"); $this->stackRoot = $this->joinPath($this->rootPath, 'source', 'stack'); $this->stackConfigPath = $this->joinPath($this->stackRoot, 'stack.json'); $this->backupRoot = $this->joinPath($this->rootPath, 'runtime', 'agents', 'stack-backup'); $this->stateFile = $this->joinPath($this->rootPath, 'runtime', 'agents', 'stack-current.json'); } public function listModes(): array { $config = $this->loadConfig(); $modes = []; foreach ($config['modes'] as $mode => $meta) { $modes[] = [ 'mode' => $mode, 'description' => (string)($meta['description'] ?? ''), 'author_only' => (bool)($meta['author_only'] ?? false), ]; } return $modes; } public function getCurrentState(): array { if (!is_file($this->stateFile)) { return [ 'mode' => null, 'switched_at' => null, 'backup_id' => null, ]; } $data = $this->readJsonFile($this->stateFile); return [ 'mode' => (string)($data['mode'] ?? ''), 'switched_at' => (string)($data['switched_at'] ?? ''), 'backup_id' => (string)($data['backup_id'] ?? ''), ]; } public function getModePlan(string $mode): array { $mode = trim($mode); if ($mode === '') { throw new RuntimeException('模式不能为空'); } $config = $this->loadConfig(); $modes = $config['modes']; if (!isset($modes[$mode])) { throw new RuntimeException("模式不存在:{$mode}"); } $defaultMode = $config['default_mode']; $managedFiles = $config['managed_files']; $plan = []; foreach ($managedFiles as $relativePath) { $modeSource = $this->resolveModeFile($mode, $relativePath); if ($modeSource !== null) { $plan[] = [ 'file' => $relativePath, 'source_mode' => $mode, 'source_path' => $modeSource, ]; continue; } $defaultSource = $this->resolveModeFile($defaultMode, $relativePath); if ($defaultSource === null) { throw new RuntimeException("默认模式缺少文件:{$relativePath}"); } $plan[] = [ 'file' => $relativePath, 'source_mode' => $defaultMode, 'source_path' => $defaultSource, ]; } return [ 'mode' => $mode, 'default_mode' => $defaultMode, 'managed_files' => $managedFiles, 'plan' => $plan, ]; } public function applyMode(string $mode, string $operator = 'system'): array { $planData = $this->getModePlan($mode); $plan = $planData['plan']; $this->ensureDir($this->backupRoot); $backupId = date('YmdHis') . '-' . substr(md5((string)microtime(true)), 0, 8); $backupDir = $this->joinPath($this->backupRoot, $backupId); $backupFilesDir = $this->joinPath($backupDir, 'files'); $this->ensureDir($backupFilesDir); $currentState = $this->getCurrentState(); $filesMeta = []; foreach ($plan as $item) { $relativePath = $item['file']; $targetPath = $this->toRootPath($relativePath); $backupFilePath = $this->joinPath($backupFilesDir, $this->toSystemPath($relativePath)); $exists = is_file($targetPath); if ($exists) { $this->ensureDir(dirname($backupFilePath)); $content = file_get_contents($targetPath); if ($content === false) { throw new RuntimeException("读取目标文件失败:{$relativePath}"); } file_put_contents($backupFilePath, $content); } $filesMeta[] = [ 'file' => $relativePath, 'existed' => $exists, ]; } foreach ($plan as $item) { $relativePath = $item['file']; $targetPath = $this->toRootPath($relativePath); $sourcePath = $item['source_path']; $content = file_get_contents($sourcePath); if ($content === false) { throw new RuntimeException("读取模式文件失败:{$relativePath}"); } $this->ensureDir(dirname($targetPath)); file_put_contents($targetPath, $content); } $meta = [ 'backup_id' => $backupId, 'mode' => $mode, 'operator' => $operator, 'created_at' => date('c'), 'files' => $filesMeta, 'prev_state' => $currentState, ]; $this->writeJsonFile($this->joinPath($backupDir, 'meta.json'), $meta); $newState = [ 'mode' => $mode, 'switched_at' => date('c'), 'backup_id' => $backupId, 'managed_files' => array_column($plan, 'file'), ]; $this->writeJsonFile($this->stateFile, $newState); return [ 'mode' => $mode, 'backup_id' => $backupId, 'applied_files' => array_column($plan, 'file'), ]; } public function rollback(string $backupId, string $operator = 'system'): array { $backupId = trim($backupId); if ($backupId === '') { throw new RuntimeException('backup_id 不能为空'); } $backupDir = $this->joinPath($this->backupRoot, $backupId); $metaPath = $this->joinPath($backupDir, 'meta.json'); if (!is_file($metaPath)) { throw new RuntimeException("备份不存在:{$backupId}"); } $meta = $this->readJsonFile($metaPath); $files = $meta['files'] ?? []; if (!is_array($files)) { throw new RuntimeException("备份元数据损坏:{$backupId}"); } $backupFilesDir = $this->joinPath($backupDir, 'files'); foreach ($files as $item) { $relativePath = (string)($item['file'] ?? ''); if ($relativePath === '') { continue; } $relativePath = $this->normalizeRelativePath($relativePath); $targetPath = $this->toRootPath($relativePath); $existed = (bool)($item['existed'] ?? false); $backupFilePath = $this->joinPath($backupFilesDir, $this->toSystemPath($relativePath)); if ($existed) { if (!is_file($backupFilePath)) { throw new RuntimeException("备份文件缺失:{$relativePath}"); } $content = file_get_contents($backupFilePath); if ($content === false) { throw new RuntimeException("读取备份文件失败:{$relativePath}"); } $this->ensureDir(dirname($targetPath)); file_put_contents($targetPath, $content); continue; } if (is_file($targetPath)) { @unlink($targetPath); } } $currentState = $this->getCurrentState(); $restoreState = $meta['prev_state'] ?? []; $this->writeJsonFile($this->stateFile, [ 'mode' => (string)($restoreState['mode'] ?? ''), 'switched_at' => date('c'), 'backup_id' => $backupId, 'rolled_back_by' => $operator, 'rolled_back_from' => (string)($currentState['mode'] ?? ''), ]); return [ 'backup_id' => $backupId, 'restored_files' => array_map(static function ($item) { return (string)($item['file'] ?? ''); }, $files), ]; } protected function loadConfig(): array { if (!is_file($this->stackConfigPath)) { throw new RuntimeException('缺少全局清单:source/stack/stack.json'); } $config = $this->readJsonFile($this->stackConfigPath); $defaultMode = (string)($config['default_mode'] ?? ''); if ($defaultMode === '') { throw new RuntimeException('stack.json 缺少 default_mode'); } $managedFilesRaw = $config['managed_files'] ?? []; if (!is_array($managedFilesRaw) || empty($managedFilesRaw)) { throw new RuntimeException('stack.json 缺少 managed_files'); } $managedFiles = []; foreach ($managedFilesRaw as $item) { $managedFiles[] = $this->normalizeRelativePath((string)$item); } $managedFiles = array_values(array_unique($managedFiles)); $modes = $config['modes'] ?? []; if (!is_array($modes) || empty($modes)) { throw new RuntimeException('stack.json 缺少 modes'); } if (!isset($modes[$defaultMode])) { throw new RuntimeException('default_mode 未在 modes 中声明'); } return [ 'default_mode' => $defaultMode, 'managed_files' => $managedFiles, 'modes' => $modes, ]; } protected function resolveModeFile(string $mode, string $relativePath): ?string { $filePath = $this->joinPath($this->stackRoot, $mode, $this->toSystemPath($relativePath)); if (is_file($filePath)) { return $filePath; } return null; } protected function toRootPath(string $relativePath): string { return $this->joinPath($this->rootPath, $this->toSystemPath($relativePath)); } protected function normalizeRelativePath(string $path): string { $path = str_replace('\\', '/', trim($path)); $path = preg_replace('#/+#', '/', $path); $path = trim((string)$path, '/'); if ($path === '') { throw new RuntimeException('检测到空路径'); } if (strpos($path, '..') !== false) { throw new RuntimeException("不允许越级路径:{$path}"); } if (preg_match('/^[A-Za-z]:/', $path)) { throw new RuntimeException("不允许绝对路径:{$path}"); } return $path; } protected function toSystemPath(string $path): string { return str_replace('/', DIRECTORY_SEPARATOR, $path); } protected function joinPath(string ...$parts): string { $result = ''; foreach ($parts as $part) { $part = trim($part); if ($part === '') { continue; } if ($result === '') { $result = rtrim($part, "\\/"); continue; } $result .= DIRECTORY_SEPARATOR . trim($part, "\\/"); } return $result; } protected function ensureDir(string $dir): void { if ($dir === '' || is_dir($dir)) { return; } if (!@mkdir($dir, 0777, true) && !is_dir($dir)) { throw new RuntimeException("创建目录失败:{$dir}"); } } protected function readJsonFile(string $file): array { $raw = file_get_contents($file); if ($raw === false) { throw new RuntimeException("读取文件失败:{$file}"); } $data = json_decode($raw, true); if (!is_array($data)) { throw new RuntimeException("JSON 解析失败:{$file}"); } return $data; } protected function writeJsonFile(string $file, array $data): void { $this->ensureDir(dirname($file)); $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); if ($json === false) { throw new RuntimeException("JSON 编码失败:{$file}"); } file_put_contents($file, $json . PHP_EOL); } }