Files
ulthon_admin/extend/base/common/service/stack/StackModeServiceBase.php
augushong b44fcfd86c feat(stack): 新增 stack 模式管理功能
- 新增 `php think admin:stack:mode` 命令,支持 list/use/current/rollback 操作
- 新增 StackModeService 服务,负责模式切换、备份与回滚逻辑
- 在 source/stack/ 目录下添加 default、full、base-build 三种模式的配置文件
- 更新 UlthonAdminService 以注册新的命令行工具
2026-04-24 23:20:13 +08:00

372 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}