diff --git a/app/common/command/admin/stack/AdminStackMode.php b/app/common/command/admin/stack/AdminStackMode.php deleted file mode 100644 index 0a5e361..0000000 --- a/app/common/command/admin/stack/AdminStackMode.php +++ /dev/null @@ -1,9 +0,0 @@ -loadConfig(); - $modes = $config['modes']; - if (!isset($modes[$mode])) { - throw new RuntimeException("模式不存在:{$mode}"); - } - - $defaultMode = $config['default_mode']; - $managedFiles = $config['managed_files']; - - $plan = []; - $skippedFiles = []; - 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) { - $plan[] = [ - 'file' => $relativePath, - 'source_mode' => $defaultMode, - 'source_path' => $defaultSource, - ]; - continue; - } - - // File not found in mode OR default - skip it (don't throw) - $skippedFiles[] = $relativePath; - } - - return [ - 'mode' => $mode, - 'default_mode' => $defaultMode, - 'managed_files' => $managedFiles, - 'plan' => $plan, - 'skipped_files' => $skippedFiles, - ]; - } - - public function applyMode(string $mode, string $operator = 'system'): array - { - // Use our overridden getModePlan (skips missing files instead of throwing) - $result = parent::applyMode($mode, $operator); - - $appliedFiles = $result['applied_files'] ?? []; - $backupId = $result['backup_id'] ?? ''; - - $config = $this->loadConfig(); - $managedFiles = $config['managed_files']; - - $deletedFiles = []; - - foreach ($managedFiles as $relativePath) { - // Skip files already handled by parent - if (in_array($relativePath, $appliedFiles)) { - continue; - } - - $targetPath = $this->toRootPath($relativePath); - if (!is_file($targetPath)) { - continue; - } - - // This file was skipped by getModePlan (not in mode or default) - // Back it up before deleting - if ($backupId !== '') { - $backupDir = $this->joinPath($this->backupRoot, $backupId); - $backupFilesDir = $this->joinPath($backupDir, 'files'); - $backupFilePath = $this->joinPath($backupFilesDir, $this->toSystemPath($relativePath)); - - $this->ensureDir(dirname($backupFilePath)); - copy($targetPath, $backupFilePath); - - // Update backup meta - $metaPath = $this->joinPath($backupDir, 'meta.json'); - if (is_file($metaPath)) { - $meta = $this->readJsonFile($metaPath); - $meta['files'][] = [ - 'file' => $relativePath, - 'existed' => true, - ]; - $this->writeJsonFile($metaPath, $meta); - } - - // Only delete if we successfully backed up - @unlink($targetPath); - $deletedFiles[] = $relativePath; - } - } - - $result['deleted_files'] = $deletedFiles; - return $result; - } -} diff --git a/extend/base/common/command/admin/stack/AdminStackModeBase.php b/extend/base/common/command/admin/stack/AdminStackModeBase.php deleted file mode 100644 index 4f6c3b6..0000000 --- a/extend/base/common/command/admin/stack/AdminStackModeBase.php +++ /dev/null @@ -1,148 +0,0 @@ -setName('admin:stack:mode') - ->setDescription('管理 Stack 模式:list/use/current/rollback') - ->addArgument('action', Argument::OPTIONAL, '操作:list|use|current|rollback', 'list') - ->addArgument('value', Argument::OPTIONAL, 'mode 或 backup_id') - ->addOption('force', 'f', Option::VALUE_NONE, '跳过确认步骤'); - } - - protected function execute(Input $input, Output $output) - { - $action = strtolower((string)$input->getArgument('action')); - $value = (string)$input->getArgument('value'); - $force = (bool)$input->getOption('force'); - - try { - /** @var StackModeService $service */ - $service = app(StackModeService::class); - - switch ($action) { - case 'list': - $this->handleList($service, $output); - break; - case 'current': - $this->handleCurrent($service, $output); - break; - case 'use': - $this->handleUse($service, $input, $output, $value, $force); - break; - case 'rollback': - $this->handleRollback($service, $input, $output, $value, $force); - break; - default: - throw new RuntimeException('不支持的 action:' . $action); - } - } catch (\Throwable $e) { - $output->error($e->getMessage()); - } - } - - protected function handleList(StackModeService $service, Output $output): void - { - $modes = $service->listModes(); - if (empty($modes)) { - $output->warning('未发现可用模式'); - return; - } - - $output->writeln('可用模式:'); - foreach ($modes as $item) { - $mode = (string)$item['mode']; - $desc = (string)$item['description']; - $authorOnly = (bool)$item['author_only'] ? 'yes' : 'no'; - $output->writeln("- {$mode} | author_only={$authorOnly} | {$desc}"); - } - } - - protected function handleCurrent(StackModeService $service, Output $output): void - { - $state = $service->getCurrentState(); - $output->writeln('当前模式:' . ((string)$state['mode'] === '' ? '未记录' : (string)$state['mode'])); - $output->writeln('切换时间:' . ((string)$state['switched_at'] === '' ? '未记录' : (string)$state['switched_at'])); - $output->writeln('备份ID:' . ((string)$state['backup_id'] === '' ? '未记录' : (string)$state['backup_id'])); - } - - protected function handleUse( - StackModeService $service, - Input $input, - Output $output, - string $mode, - bool $force - ): void { - $mode = trim($mode); - if ($mode === '') { - throw new RuntimeException('请提供 mode,例如:admin:stack:mode use base-build'); - } - - $planData = $service->getModePlan($mode); - $output->writeln("将切换到模式:{$mode}"); - foreach ($planData['plan'] as $item) { - $file = (string)$item['file']; - $sourceMode = (string)$item['source_mode']; - $output->writeln("- {$file} <= {$sourceMode}"); - } - - $skippedFiles = $planData['skipped_files'] ?? []; - if (!empty($skippedFiles)) { - $output->warning('以下文件在该模式下不存在,将从项目根目录删除:'); - foreach ($skippedFiles as $skipped) { - $output->writeln("- [删除] {$skipped}"); - } - } - - if (!$force && !$output->confirm($input, '确认执行覆盖切换?', true)) { - $output->warning('已取消'); - return; - } - - $result = $service->applyMode($mode, get_current_user() ?: 'cli'); - $output->info('模式切换完成'); - $output->writeln('mode=' . $result['mode']); - $output->writeln('backup_id=' . $result['backup_id']); - - $deletedFiles = $result['deleted_files'] ?? []; - if (!empty($deletedFiles)) { - $output->warning('已删除文件:' . implode(', ', $deletedFiles)); - } - } - - protected function handleRollback( - StackModeService $service, - Input $input, - Output $output, - string $backupId, - bool $force - ): void { - $backupId = trim($backupId); - if ($backupId === '') { - throw new RuntimeException('请提供 backup_id,例如:admin:stack:mode rollback 20260424120000-abcd1234'); - } - - if (!$force && !$output->confirm($input, "确认回滚到备份 {$backupId}?", true)) { - $output->warning('已取消'); - return; - } - - $result = $service->rollback($backupId, get_current_user() ?: 'cli'); - $output->info('回滚完成'); - $output->writeln('backup_id=' . $result['backup_id']); - $output->writeln('restored_files=' . count($result['restored_files'])); - } -} diff --git a/extend/base/common/service/stack/StackModeServiceBase.php b/extend/base/common/service/stack/StackModeServiceBase.php deleted file mode 100644 index 57a5701..0000000 --- a/extend/base/common/service/stack/StackModeServiceBase.php +++ /dev/null @@ -1,371 +0,0 @@ -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); - } -} diff --git a/extend/think/UlthonAdminService.php b/extend/think/UlthonAdminService.php index c365825..e9a7734 100644 --- a/extend/think/UlthonAdminService.php +++ b/extend/think/UlthonAdminService.php @@ -25,7 +25,6 @@ use app\common\command\admin\role\AdminRoleList; use app\common\command\admin\role\AdminRolePermissionAssign; use app\common\command\admin\role\AdminRolePermissionList; use app\common\command\admin\role\AdminRolePermissionRevoke; -use app\common\command\admin\stack\AdminStackMode; use app\common\command\admin\timer\TimerLogClean; use app\common\command\admin\user\AdminUserRoleAssign; use app\common\command\admin\user\AdminUserRoleList; @@ -115,7 +114,6 @@ class UlthonAdminService extends Service AdminRolePermissionAssign::class, AdminRolePermissionRevoke::class, AdminRolePermissionList::class, - AdminStackMode::class, TimerLogClean::class, AdminUserRoleAssign::class, AdminUserRoleRevoke::class,