From 9b4fc4245703b92eb79205c2fd5b248f351d4562 Mon Sep 17 00:00:00 2001
From: shaw
Date: Thu, 18 Dec 2025 21:15:10 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=90=8E=E5=8F=B0?=
=?UTF-8?q?=E5=9C=A8=E7=BA=BF=E6=9B=B4=E6=96=B0=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 前端添加更新和重启按钮,支持一键更新 Release 构建
- 修复条件判断优先级问题,确保错误/成功状态正确显示
- 后端使用原子文件替换模式,确保更新过程安全可靠
- 在可执行文件同目录创建临时文件,保证 rename 原子性
- 删除未使用的 copyFile 函数,保持代码整洁
---
backend/internal/service/update_service.go | 66 +++----
frontend/src/api/admin/system.ts | 33 ++++
.../src/components/common/VersionBadge.vue | 175 +++++++++++++++---
frontend/src/i18n/locales/en.ts | 11 +-
frontend/src/i18n/locales/zh.ts | 11 +-
5 files changed, 238 insertions(+), 58 deletions(-)
diff --git a/backend/internal/service/update_service.go b/backend/internal/service/update_service.go
index b25bd87e..0cadff47 100644
--- a/backend/internal/service/update_service.go
+++ b/backend/internal/service/update_service.go
@@ -125,6 +125,7 @@ func (s *UpdateService) CheckUpdate(ctx context.Context, force bool) (*UpdateInf
}
// PerformUpdate downloads and applies the update
+// Uses atomic file replacement pattern for safe in-place updates
func (s *UpdateService) PerformUpdate(ctx context.Context) error {
info, err := s.CheckUpdate(ctx, true)
if err != nil {
@@ -173,8 +174,11 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error {
return fmt.Errorf("failed to resolve symlinks: %w", err)
}
- // Create temp directory for extraction
- tempDir, err := os.MkdirTemp("", "sub2api-update-*")
+ exeDir := filepath.Dir(exePath)
+
+ // Create temp directory in the SAME directory as executable
+ // This ensures os.Rename is atomic (same filesystem)
+ tempDir, err := os.MkdirTemp(exeDir, ".sub2api-update-*")
if err != nil {
return fmt.Errorf("failed to create temp dir: %w", err)
}
@@ -199,23 +203,36 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error {
return fmt.Errorf("extraction failed: %w", err)
}
- // Backup current binary
- backupFile := exePath + ".backup"
- if err := os.Rename(exePath, backupFile); err != nil {
- return fmt.Errorf("backup failed: %w", err)
- }
-
- // Replace with new binary
- if err := copyFile(newBinaryPath, exePath); err != nil {
- os.Rename(backupFile, exePath)
- return fmt.Errorf("replace failed: %w", err)
- }
-
- // Make executable
- if err := os.Chmod(exePath, 0755); err != nil {
+ // Set executable permission before replacement
+ if err := os.Chmod(newBinaryPath, 0755); err != nil {
return fmt.Errorf("chmod failed: %w", err)
}
+ // Atomic replacement using rename pattern:
+ // 1. Rename current -> backup (atomic on Unix)
+ // 2. Rename new -> current (atomic on Unix, same filesystem)
+ // If step 2 fails, restore backup
+ backupPath := exePath + ".backup"
+
+ // Remove old backup if exists
+ os.Remove(backupPath)
+
+ // Step 1: Move current binary to backup
+ if err := os.Rename(exePath, backupPath); err != nil {
+ return fmt.Errorf("backup failed: %w", err)
+ }
+
+ // Step 2: Move new binary to target location (atomic, same filesystem)
+ if err := os.Rename(newBinaryPath, exePath); err != nil {
+ // Restore backup on failure
+ if restoreErr := os.Rename(backupPath, exePath); restoreErr != nil {
+ return fmt.Errorf("replace failed and restore failed: %w (restore error: %v)", err, restoreErr)
+ }
+ return fmt.Errorf("replace failed (restored backup): %w", err)
+ }
+
+ // Success - backup file is kept for rollback capability
+ // It will be cleaned up on next successful update
return nil
}
@@ -515,23 +532,6 @@ func (s *UpdateService) extractBinary(archivePath, destPath string) error {
return err
}
-func copyFile(src, dst string) error {
- in, err := os.Open(src)
- if err != nil {
- return err
- }
- defer in.Close()
-
- out, err := os.Create(dst)
- if err != nil {
- return err
- }
- defer out.Close()
-
- _, err = io.Copy(out, in)
- return err
-}
-
func (s *UpdateService) getFromCache(ctx context.Context) (*UpdateInfo, error) {
data, err := s.rdb.Get(ctx, updateCacheKey).Result()
if err != nil {
diff --git a/frontend/src/api/admin/system.ts b/frontend/src/api/admin/system.ts
index 6f13856b..cb3e39a5 100644
--- a/frontend/src/api/admin/system.ts
+++ b/frontend/src/api/admin/system.ts
@@ -40,9 +40,42 @@ export async function checkUpdates(force = false): Promise {
return data;
}
+export interface UpdateResult {
+ message: string;
+ need_restart: boolean;
+}
+
+/**
+ * Perform system update
+ * Downloads and applies the latest version
+ */
+export async function performUpdate(): Promise {
+ const { data } = await apiClient.post('/admin/system/update');
+ return data;
+}
+
+/**
+ * Rollback to previous version
+ */
+export async function rollback(): Promise {
+ const { data } = await apiClient.post('/admin/system/rollback');
+ return data;
+}
+
+/**
+ * Restart the service
+ */
+export async function restartService(): Promise<{ message: string }> {
+ const { data } = await apiClient.post<{ message: string }>('/admin/system/restart');
+ return data;
+}
+
export const systemAPI = {
getVersion,
checkUpdates,
+ performUpdate,
+ rollback,
+ restartService,
};
export default systemAPI;
diff --git a/frontend/src/components/common/VersionBadge.vue b/frontend/src/components/common/VersionBadge.vue
index 8b86728a..93c77e0d 100644
--- a/frontend/src/components/common/VersionBadge.vue
+++ b/frontend/src/components/common/VersionBadge.vue
@@ -69,8 +69,63 @@
-
-
+
+
+
+
+
+
{{ t('version.updateFailed') }}
+
{{ updateError }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('version.updateComplete') }}
+
{{ t('version.restartRequired') }}
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
(null);
const buildType = ref('source'); // "source" or "release"
+// Update process states
+const updating = ref(false);
+const restarting = ref(false);
+const needRestart = ref(false);
+const updateError = ref('');
+const updateSuccess = ref(false);
+
// Only show update check for release builds (binary/docker deployment)
const isReleaseBuild = computed(() => buildType.value === 'release');
@@ -200,6 +286,10 @@ async function refreshVersion(force = true) {
// Show update indicator for all build types
hasUpdate.value = data.has_update;
releaseInfo.value = data.release_info || null;
+ // Reset update states when refreshing
+ updateError.value = '';
+ updateSuccess.value = false;
+ needRestart.value = false;
} catch (error) {
console.error('Failed to check updates:', error);
} finally {
@@ -207,6 +297,45 @@ async function refreshVersion(force = true) {
}
}
+async function handleUpdate() {
+ if (updating.value) return;
+
+ updating.value = true;
+ updateError.value = '';
+ updateSuccess.value = false;
+
+ try {
+ const result = await performUpdate();
+ updateSuccess.value = true;
+ needRestart.value = result.need_restart;
+ hasUpdate.value = false;
+ } catch (error: unknown) {
+ const err = error as { response?: { data?: { message?: string } }; message?: string };
+ updateError.value = err.response?.data?.message || err.message || t('version.updateFailed');
+ } finally {
+ updating.value = false;
+ }
+}
+
+async function handleRestart() {
+ if (restarting.value) return;
+
+ restarting.value = true;
+
+ try {
+ await restartService();
+ // Service will restart, page will reload automatically or show disconnected
+ } catch (error) {
+ // Expected - connection will be lost during restart
+ console.log('Service restarting...');
+ }
+
+ // Show restarting state for a while, then reload
+ setTimeout(() => {
+ window.location.reload();
+ }, 3000);
+}
+
function handleClickOutside(event: MouseEvent) {
const target = event.target as Node;
const button = (event.target as Element).closest('button');
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index f22f8ae8..e6465db1 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -1023,9 +1023,18 @@ export default {
noReleaseNotes: 'No release notes',
viewUpdate: 'View Update',
viewRelease: 'View Release',
+ viewChangelog: 'View Changelog',
refresh: 'Refresh',
sourceMode: 'Source Build',
- sourceModeHint: 'Update detection is disabled for source builds. Use git pull to update.',
+ sourceModeHint: 'Source build, use git pull to update',
+ updateNow: 'Update Now',
+ updating: 'Updating...',
+ updateComplete: 'Update Complete',
+ updateFailed: 'Update Failed',
+ restartRequired: 'Please restart the service to apply the update',
+ restartNow: 'Restart Now',
+ restarting: 'Restarting...',
+ retry: 'Retry',
},
// User Subscriptions Page
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index e6b83ab4..322fc9f4 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -1202,9 +1202,18 @@ export default {
noReleaseNotes: '暂无更新日志',
viewUpdate: '查看更新',
viewRelease: '查看发布',
+ viewChangelog: '查看更新日志',
refresh: '刷新',
sourceMode: '源码构建',
- sourceModeHint: '源码构建模式不支持更新检测,请使用 git pull 更新代码。',
+ sourceModeHint: '源码构建请使用 git pull 更新',
+ updateNow: '立即更新',
+ updating: '正在更新...',
+ updateComplete: '更新完成',
+ updateFailed: '更新失败',
+ restartRequired: '请重启服务以应用更新',
+ restartNow: '立即重启',
+ restarting: '正在重启...',
+ retry: '重试',
},
// User Subscriptions Page