['type' => 'add|delete|update', 'category' => 'optional|force']] /** * @var Input */ public $input; /** * @var Output */ public $output; public function __construct($type = 'ulthon_admin') { if ($type == 'ulthon_admin' || $type == null) { $this->useRepo = self::REPO; $this->useVersion = Version::VERSION; } else { $this->useRepo = static::PRODUCT_REPO; $this->useVersion = Version::PRODUCT_VERSION; } } public function update() { $output = $this->output; $input = $this->input; $this->cleanWorkpaceDir(); $current_version = $this->useVersion; $current_version_dir = App::getRuntimePath() . '/update/current'; $last_version_dir = App::getRuntimePath() . '/update/repo'; $now_dir = App::getRootPath(); $output->writeln('获取最新代码'); $last_version_git = new Git(); $last_version_repo = $last_version_git->cloneRepository($this->useRepo, $last_version_dir); $is_update_master = (bool)$input->getOption('update-master'); $last_version = null; if ($is_update_master) { $output->writeln('当前更新源:master'); $output->writeln('切换最新代码到 master 分支'); try { $last_version_repo->checkout('master'); } catch (\Throwable $e) { $output->error('切换 master 分支失败,请检查远程分支名是否为 master'); $this->cleanWorkpaceDir(); return; } $last_version = 'master'; } else { $output->writeln('当前更新源:latest tag'); $tags = $last_version_repo->getTags(); $update_level = Env::get('adminsystem.update_level', 'production'); if ($update_level == 'production') { $tags = array_filter($tags, function ($value) { if (strpos($value, '-')) { return false; } return true; }); } usort($tags, function ($a, $b) { return version_compare($a, $b); }); $last_version = $tags[count($tags) - 1]; if ($last_version == $current_version) { $output->writeln('当前版本为最新版本'); if ((bool)$input->getOption('reinstall')) { $output->writeln('重装代码'); } else { $this->cleanWorkpaceDir(); return; } } // 将最新代码切换到最新版本,因为最新的提交可能没有发布版本 $output->writeln('切换最新代码的最新版本'); $last_version_repo->checkout($last_version); } $output->writeln('复制当前版本代码'); $output->writeln('切换到当前版本' . $current_version); $last_version_repo->checkout($current_version); $this->copyDirectory($last_version_dir, $current_version_dir); $output->writeln('切换回最新版本' . $last_version); $last_version_repo->checkout($last_version); $output->writeln('开始比较版本差异'); $current_version_filesystem = new Filesystem(new LocalFilesystemAdapter($current_version_dir)); $last_version_filesystem = new Filesystem(new LocalFilesystemAdapter($last_version_dir)); $now_filesystem = new Filesystem(new LocalFilesystemAdapter($now_dir)); // app、config下的所有文件和根目录下的几个文件 // 如果与当前版本一致(没有定制过),则将这种文件增加到完全跟踪列表,(新版覆盖、新版删除), // 否则不会覆盖和删除,但会将新文件追加到相应目录下 // 其余的所有代码都应当完全跟踪 // 完全跳过runtime、vendor、.git目录 $ignore_prefix = [ 'runtime', 'vendor', '.git', 'public/storage', 'public/build', ]; // 当前版本的应该被处理所有文件 $current_version_files = $this->collectTrackedFiles($current_version_filesystem, $ignore_prefix); // 最新版本的所有文件 $last_version_files = $this->collectTrackedFiles($last_version_filesystem, $ignore_prefix); // 本身的所有文件 $now_files = $this->collectTrackedFiles($now_filesystem, $ignore_prefix); $changed_files = []; // 需要删除的文件 $need_delete_files = array_diff($now_files, $last_version_files); foreach ($need_delete_files as $file_path) { $changed_files[$file_path] = 'delete'; } // 需要增加的文件 $need_add_files = array_diff($last_version_files, $now_files); foreach ($need_add_files as $file_path) { $changed_files[$file_path] = 'add'; } // 需要更新的文件 $need_update_files = array_intersect($now_files, $last_version_files); foreach ($need_update_files as $file_path) { $changed_files[$file_path] = 'update'; } // 提示用户有一些完全跟踪的文件被修改了 $optional_update_waring_files = []; $force_update_waring_files = []; $need_process_files = []; foreach ($changed_files as $file_path => $type) { if ($type == 'add') { if ($this->testIsOptionalFiles($file_path)) { // 最新版本有,但是现存版本没有,有可能是新版本增加的,也有可能是用户删除的 // 判断是否是新版本增加的 if (!in_array($file_path, $current_version_files)) { // 如果是新版本增加的,则直接处理 $need_process_files[$file_path] = $type; continue; } // 如果是用户删除了的,则提示用户处理 $optional_update_waring_files[$file_path] = $type; continue; } // 如果是强制更新的部分,则应当直接处理 $need_process_files[$file_path] = $type; continue; } if ($type == 'delete') { if ($this->testIsOptionalFiles($file_path)) { // 最新版本没有,但是现存版本有,有可能是新版本删除的,也有可能是用户增加的 if (in_array($file_path, $current_version_files)) { // 如果这个文件在当前版本中存在,则是新版本删除的,提示用户处理,因为用户改动了他,可能不希望被删除 $optional_update_waring_files[$file_path] = $type; continue; } // 如果这个文件在当前版本中不存在,则是用户增加的,跳过 continue; } // 如果是强制更新的部分 if (in_array($file_path, $current_version_files)) { // 如果这个新建的文件,在当前版本中存在,则是新版本删除的,直接处理 $need_process_files[$file_path] = $type; continue; } // 如果这个文件在当前版本中不存在,则是用户增加的,跳过 continue; } $now_file_path = $now_dir . '/' . $file_path; $current_file_path = $current_version_dir . '/' . $file_path; $last_file_path = $last_version_dir . '/' . $file_path; // 如果现存文件和新版本一致,则无需处理 if (PathTools::compareFiles($now_file_path, $last_file_path)) { continue; } // 如果现存版本和当前版本一致,则直接处理 if (PathTools::compareFiles($now_file_path, $current_file_path)) { if (PathTools::compareFiles($current_file_path, $last_file_path)) { // 如果当前版本和新版本一致,则无需处理 continue; } // 如果当前代码 和 当前版本 一致,则直接处理 $need_process_files[$file_path] = $type; continue; } if ($this->testIsOptionalFiles($file_path)) { // 可选更新的文件发生了变化,提示用户手动维护上游信息 $optional_update_waring_files[$file_path] = $type; } else { // 强制更新的文件被定制了,需要提醒可能会产生错误 $force_update_waring_files[$file_path] = $type; } } if (!empty($optional_update_waring_files)) { $strategy = $this->optionalConflict ?: null; // empty string → null if ($this->dryRun) { if ($strategy === null) { // 未传参数:保持现有 dry-run 行为(合并所有冲突文件) $need_process_files = array_merge($need_process_files, $optional_update_waring_files); } else { // 显式传了策略:按策略决定 $effective = ($strategy === 'ask') ? 'skip' : $strategy; if ($effective === 'overwrite') { $need_process_files = array_merge($need_process_files, $optional_update_waring_files); } else { // skip: 记录被跳过的文件(供输出增强使用) $this->skippedConflictFiles = array_merge( $this->skippedConflictFiles, array_map(fn($t) => ['type' => $t, 'category' => 'optional'], $optional_update_waring_files) ); } } } elseif ($strategy !== null && $strategy !== 'ask') { // 显式指定了非 ask 策略,静默处理 if ($strategy === 'overwrite') { $need_process_files = array_merge($need_process_files, $optional_update_waring_files); } // skip: 不加入 } else { // null 或 ask: 走原有交互确认(完全保持现有行为) foreach ($optional_update_waring_files as $file_path => $type) { $output->writeln($file_path . ' ' . $type); } $output->writeln('以上文件被您修改了,这些文件是默认的系统文件,并非您的主要业务代码,'); $output->writeln('您可能通过扩展机制修改了以上文件来定制系统代码的逻辑'); $output->writeln('您可能需要根据扩展规则查看系统逻辑是否发生了变化,如果发生了变化,您需要手动修改这些文件'); $is_udpate_optinal_files = $output->confirm($input, '确定要更新吗?(建议不更新)', false); if ($is_udpate_optinal_files) { $need_process_files = array_merge($need_process_files, $optional_update_waring_files); } } } if (!empty($force_update_waring_files)) { $strategy = $this->forceConflict ?: null; // empty string → null if ($this->dryRun) { if ($strategy === null) { // 未传参数:保持现有 dry-run 行为(合并所有冲突文件) $need_process_files = array_merge($need_process_files, $force_update_waring_files); } else { $effective = ($strategy === 'ask') ? 'overwrite' : $strategy; if ($effective === 'overwrite') { $need_process_files = array_merge($need_process_files, $force_update_waring_files); } else { // skip: 记录被跳过的文件 $this->skippedConflictFiles = array_merge( $this->skippedConflictFiles, array_map(fn($t) => ['type' => $t, 'category' => 'force'], $force_update_waring_files) ); } } } elseif ($strategy !== null && $strategy !== 'ask') { if ($strategy === 'overwrite') { $need_process_files = array_merge($need_process_files, $force_update_waring_files); } } else { // null 或 ask: 走原有交互确认 foreach ($force_update_waring_files as $file_path => $type) { $output->writeln($file_path . ' ' . $type); } $output->writeln('以上文件被您定制了,您不应该修改这些文件,'); $output->writeln('但您出于某些原因修改了他们,如果继续更新,会覆盖至最新版本,'); $output->writeln('这些改动不应该发生,继续自动升级可能会导致错误,'); $output->writeln('建议您选择更新,然后将这些改动的逻辑通过扩展的机制重新实现'); $is_udpate_force_files = $output->confirm($input, '确定要更新吗?(建议更新)', false); if ($is_udpate_force_files) { $need_process_files = array_merge($need_process_files, $force_update_waring_files); } } } if (empty($need_process_files)) { $output->writeln('没有需要更新的文件'); $this->cleanWorkpaceDir(); return; } // 处理需要更新的文件 if (!$this->dryRun) { foreach ($need_process_files as $file_path => $type) { $now_file_path = $now_dir . '/' . $file_path; $last_file_path = $last_version_dir . '/' . $file_path; PathTools::intiDir($now_file_path); if ($type == 'delete') { $output->writeln('删除文件' . $now_file_path); unlink($now_file_path); } elseif ($type == 'add') { $output->writeln('增加文件' . $now_file_path); copy($last_file_path, $now_file_path); } elseif ($type == 'update') { $output->writeln('更新文件' . $now_file_path); copy($last_file_path, $now_file_path); } } } if ($this->dryRun && !empty($need_process_files)) { $add_count = count(array_filter($need_process_files, fn($t) => $t === 'add')); $delete_count = count(array_filter($need_process_files, fn($t) => $t === 'delete')); $update_count = count(array_filter($need_process_files, fn($t) => $t === 'update')); $output->writeln(''); $output->writeln('[预览模式] 以下文件将被变更:'); // Determine which files to show $showScope = $this->showScope ?: 'all'; $display_files = []; if ($showScope === 'conflict') { // Only show conflict files foreach ($optional_update_waring_files as $fp => $t) { $label = "[{$t}]"; if (isset($need_process_files[$fp])) { $label .= "[conflict:optional:overwrite]"; } else { $label .= "[skipped][conflict:optional:skip]"; } $display_files[$fp] = $label; } foreach ($force_update_waring_files as $fp => $t) { $label = "[{$t}]"; if (isset($need_process_files[$fp])) { $label .= "[conflict:force:overwrite]"; } else { $label .= "[skipped][conflict:force:skip]"; } $display_files[$fp] = $label; } } else { // Show all files (default) // First add all non-conflict files from need_process_files foreach ($need_process_files as $fp => $t) { $label = "[{$t}]"; // Check if this is a conflict file if (isset($optional_update_waring_files[$fp])) { $label .= "[conflict:optional:overwrite]"; } elseif (isset($force_update_waring_files[$fp])) { $label .= "[conflict:force:overwrite]"; } $display_files[$fp] = $label; } // Then add skipped conflict files foreach ($this->skippedConflictFiles as $fp => $info) { $t = $info['type']; $cat = $info['category']; $display_files[$fp] = "[{$t}][skipped][conflict:{$cat}:skip]"; } } // Group by directory if > 5 files if (count($display_files) > 5) { $groups = []; $dir_order = ['extend/base/', 'extend/think/', 'app/', 'config/', 'route/', 'public/', 'database/', 'composer']; foreach ($display_files as $fp => $label) { $group_name = '其他'; foreach ($dir_order as $prefix) { if (str_starts_with($fp, $prefix)) { $group_name = $prefix; break; } } // Root files (no /) if (strpos($fp, '/') === false) { $group_name = '根目录'; } $groups[$group_name][$fp] = $label; } foreach ($groups as $group_name => $files) { $output->writeln(" [{$group_name}]"); foreach ($files as $fp => $label) { $output->writeln(" {$label} " . $fp); } } } else { foreach ($display_files as $fp => $label) { $output->writeln(" {$label} " . $fp); } } $output->writeln("统计: 新增 {$add_count}, 删除 {$delete_count}, 更新 {$update_count}"); // Risk summary $force_conflict_count = count($force_update_waring_files); $optional_conflict_count = count($optional_update_waring_files); $no_conflict_count = count($need_process_files) - $force_conflict_count - $optional_conflict_count; if ($no_conflict_count < 0) $no_conflict_count = 0; $output->writeln("风险评估: 强制冲突 {$force_conflict_count}个(高风险) | 可选冲突 {$optional_conflict_count}个(中风险) | 无冲突变更 {$no_conflict_count}个(低风险)"); } // 非 dry-run 模式:仅在显式传了 --show 时输出变更摘要 if (!$this->dryRun && $this->showScope !== null && !empty($need_process_files)) { $showScope = $this->showScope ?: 'all'; $output->writeln(''); $output->writeln('变更摘要:'); if ($showScope === 'conflict') { $conflict_count = count($optional_update_waring_files) + count($force_update_waring_files); $output->writeln(" 冲突文件: {$conflict_count}个"); foreach ($optional_update_waring_files as $fp => $t) { $processed = isset($need_process_files[$fp]) ? '已处理' : '已跳过'; $output->writeln(" [可选][{$processed}] " . $fp); } foreach ($force_update_waring_files as $fp => $t) { $processed = isset($need_process_files[$fp]) ? '已处理' : '已跳过'; $output->writeln(" [强制][{$processed}] " . $fp); } } else { $output->writeln(" 变更文件: " . count($need_process_files) . "个"); } $force_conflict_count = count($force_update_waring_files); $optional_conflict_count = count($optional_update_waring_files); $no_conflict_count = count($need_process_files) - $force_conflict_count - $optional_conflict_count; if ($no_conflict_count < 0) $no_conflict_count = 0; $output->writeln("风险评估: 强制冲突 {$force_conflict_count}个(高风险) | 可选冲突 {$optional_conflict_count}个(中风险) | 无冲突变更 {$no_conflict_count}个(低风险)"); } // 对比 composer.json 依赖差异 $this->compareComposerRequire($current_version_dir, $last_version_dir, $output); $this->showPostUpdateGuidance($need_process_files, $output); $output->writeln('更新完成'); // 更新完成 $update_tips_file_path = $last_version_dir . '/app/admin/service/adminUpdateData/tips.php'; if(!file_exists($update_tips_file_path)){ $update_tips_file_path = $last_version_dir . '/extend/base/admin/service/adminUpdateData/tips.php'; } $update_tips = include $update_tips_file_path; // 按照版本号排序 usort($update_tips, function ($a, $b) { return version_compare($a['version'], $b['version']); }); foreach ($update_tips as $tips_item) { if (version_compare($tips_item['version'], $current_version) <= 0) { continue; } $output->writeln('版本' . $tips_item['version'] . '更新说明:'); $output->writeln('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>'); foreach ($tips_item['desc'] as $desc) { $output->writeln($desc); } } $this->cleanWorkpaceDir(); } protected function showPostUpdateGuidance(array $need_process_files, Output $output): void { if (empty($need_process_files)) { return; } $file_paths = array_keys($need_process_files); $guidance = []; // Check if composer.json changed foreach ($file_paths as $path) { if ($path === 'composer.json') { $guidance[] = ['composer install', 'composer.json 已变更,请更新依赖']; break; } } // Check if database migrations changed foreach ($file_paths as $path) { if (str_starts_with($path, 'database/migrations/')) { $guidance[] = ['php think migrate:run', '数据库迁移文件已变更,请执行迁移']; break; } } // Check if extend/base or config changed (affects cache) foreach ($file_paths as $path) { if (str_starts_with($path, 'extend/base/') || str_starts_with($path, 'config/')) { $guidance[] = ['php think clear', '框架核心或配置已变更,请清理缓存']; break; } } if (!empty($guidance)) { $output->writeln(''); $output->writeln('建议执行以下后续操作:'); foreach ($guidance as $item) { $output->writeln(" [{$item[1]}] → {$item[0]}"); } } } protected function testIsOptionalFiles($file_path) { // 如果file_path以app或config开头,则是可选更新的文件 $optional_files_prefix = [ 'app', 'config', 'route', 'source/docker', 'extend/think', ]; foreach ($optional_files_prefix as $prefix) { if ($file_path === $prefix || str_starts_with($file_path, $prefix . '/')) { return true; } } $required_file = [ 'think' ]; foreach ($required_file as $file) { if($file == $file_path){ return false; } } // 如果file_path不存在目录分隔符,则是可选更新的文件(根目录下的文件) if (strpos($file_path, '/') === false) { return true; } return false; } protected function collectTrackedFiles(Filesystem $filesystem, array $ignore_prefix, string $path = '/') { $files = []; foreach ($filesystem->listContents($path, false) as $attributes) { if ($this->isIgnoredPath($attributes->path(), $ignore_prefix)) { continue; } if ($attributes->isDir()) { $files = array_merge($files, $this->collectTrackedFiles($filesystem, $ignore_prefix, $attributes->path())); continue; } $files[] = $attributes->path(); } return $files; } protected function isIgnoredPath(string $path, array $ignore_prefix) { foreach ($ignore_prefix as $prefix) { if ($path === $prefix || str_starts_with($path, $prefix . '/')) { return true; } } return false; } protected function cleanWorkpaceDir() { $dir = App::getRuntimePath() . '/update/'; $this->output->writeln('清理目录 ' . $dir); PathTools::removeDir($dir); } /** * 递归复制目录 * Windows 兼容,使用 PHP 原生 opendir/readdir 实现 */ protected function copyDirectory(string $src, string $dst): void { if (!is_dir($src)) { return; } if (!is_dir($dst)) { mkdir($dst, 0755, true); } $handle = opendir($src); while (false !== ($file = readdir($handle))) { if ($file === '.' || $file === '..') { continue; } $srcPath = $src . '/' . $file; $dstPath = $dst . '/' . $file; if (is_dir($srcPath)) { $this->copyDirectory($srcPath, $dstPath); } else { copy($srcPath, $dstPath); } } closedir($handle); } protected function compareComposerRequire(string $current_dir, string $last_dir, Output $output): void { $current_composer_path = $current_dir . '/composer.json'; $last_composer_path = $last_dir . '/composer.json'; if (!file_exists($last_composer_path)) { return; } $last_composer = json_decode(file_get_contents($last_composer_path), true); $current_composer = file_exists($current_composer_path) ? json_decode(file_get_contents($current_composer_path), true) : []; if (!$last_composer || !is_array($last_composer)) { return; } $last_require = $last_composer['require'] ?? []; $current_require = ($current_composer && is_array($current_composer)) ? ($current_composer['require'] ?? []) : []; if (empty($last_require) && empty($current_require)) { return; } $output->writeln(''); $output->writeln('Composer 依赖变更:'); $has_changes = false; // 新增的依赖 $new_packages = array_diff_key($last_require, $current_require); foreach ($new_packages as $package => $version) { $output->writeln(" composer require {$package}:{$version}"); $has_changes = true; } // 删除的依赖 $removed_packages = array_diff_key($current_require, $last_require); foreach ($removed_packages as $package => $version) { $output->writeln(" composer remove {$package}"); $has_changes = true; } // 版本变更的依赖 $common_packages = array_intersect_key($last_require, $current_require); foreach ($common_packages as $package => $last_version) { $current_version = $current_require[$package]; if ($current_version !== $last_version) { $output->writeln(" composer require {$package}:{$last_version} (版本变更, 旧版: {$current_version})"); $has_changes = true; } } if (!$has_changes) { $output->writeln(' composer 依赖无变化'); } } }