feat(stack): 新增 stack 模式管理功能

- 新增 `php think admin:stack:mode` 命令,支持 list/use/current/rollback 操作
- 新增 StackModeService 服务,负责模式切换、备份与回滚逻辑
- 在 source/stack/ 目录下添加 default、full、base-build 三种模式的配置文件
- 更新 UlthonAdminService 以注册新的命令行工具
This commit is contained in:
augushong
2026-04-24 23:20:13 +08:00
parent 0945d42d0a
commit b44fcfd86c
15 changed files with 1010 additions and 0 deletions

View File

@@ -0,0 +1,371 @@
<?php
namespace base\common\service\stack;
use RuntimeException;
use think\facade\App;
class StackModeServiceBase
{
protected string $rootPath;
protected string $stackRoot;
protected string $stackConfigPath;
protected string $backupRoot;
protected string $stateFile;
public function __construct()
{
$this->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);
}
}