diff --git a/.github/release-notes/v1.2.7.md b/.github/release-notes/v1.2.7.md new file mode 100644 index 0000000..a702f25 --- /dev/null +++ b/.github/release-notes/v1.2.7.md @@ -0,0 +1,21 @@ +## 本次更新 + +### 修复网页更新出现 502 + +- 修复后台点击“立即更新”后,Watchtower 在替换业务容器时切断原更新请求,导致页面错误显示 `502 Bad Gateway` 或“更新失败”的问题。 +- 更新接口现在会先完成数据库备份并向网页返回 `202 Accepted`,再异步触发容器更新,避免旧容器停止时丢失响应。 +- 前端遇到更新期间的 `502`、`503`、`504`、网络中断或请求超时时,会继续轮询服务健康状态;确认目标版本启动后自动刷新页面。 +- Watchtower 调用等待时间延长到 10 分钟,兼容首次拉取较大镜像或网络较慢的服务器。 + +### 更新说明 + +- 网页更新仍会在替换容器期间产生数秒正常中断,页面会显示“正在重启服务”,恢复后自动刷新。 +- 更新前仍会自动备份 SQLite 数据库,现有邮件、账号、域名、证书和配置不会删除。 +- 从 `v1.2.6` 更新到本版时,旧页面尚未包含此次容错逻辑,建议在服务器运行 `sudo newszxcn-email update` 完成这一次升级;进入 `v1.2.7` 后,后续版本可正常使用网页更新。 + +### 验证 + +- 新增异步更新回归测试:即使 Watchtower 更新请求保持阻塞,网页也必须先收到更新已受理响应。 +- 已通过 Go API 测试、前端生产构建和 shadcn/ui 检查。 + +**完整更新日志**:[v1.2.6...v1.2.7](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.6...v1.2.7) diff --git a/README.md b/README.md index d095c77..afbff00 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i 超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。 +更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。 + 更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。 ### 命令行更新 diff --git a/VERSION b/VERSION index 3c43790..c04c650 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.6 +1.2.7 diff --git a/apps/api/internal/app/system_update_handlers.go b/apps/api/internal/app/system_update_handlers.go index 405160b..4b6cebd 100644 --- a/apps/api/internal/app/system_update_handlers.go +++ b/apps/api/internal/app/system_update_handlers.go @@ -81,12 +81,6 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "failed to back up database") return } - if err := a.triggerUpdateService(r.Context()); err != nil { - a.log.Error("trigger system update", "error", err) - respondError(w, http.StatusBadGateway, "failed to start update") - return - } - a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath) respondJSON(w, http.StatusAccepted, map[string]any{ "ok": true, @@ -94,6 +88,7 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) { "targetVersion": info.LatestVersion, "message": "更新已启动,服务会在完成后自动恢复", }) + a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion) } func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) { @@ -175,7 +170,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error { } req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken)) client := &http.Client{ - Timeout: 30 * time.Second, + Timeout: 10 * time.Minute, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, @@ -192,6 +187,18 @@ func (a *App) triggerUpdateService(ctx context.Context) error { return nil } +func (a *App) scheduleUpdateService(currentVersion, targetVersion string) { + go func() { + // Let the accepted response reach the browser before Watchtower replaces this container. + time.Sleep(250 * time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + if err := a.triggerUpdateService(ctx); err != nil { + a.log.Error("run scheduled system update", "error", err, "from", currentVersion, "to", targetVersion) + } + }() +} + func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) { backupDir := filepath.Join(a.config().DataDir, "backups") if err := os.MkdirAll(backupDir, 0o700); err != nil { diff --git a/apps/api/internal/app/system_update_handlers_test.go b/apps/api/internal/app/system_update_handlers_test.go index 57c659e..3b7dbbf 100644 --- a/apps/api/internal/app/system_update_handlers_test.go +++ b/apps/api/internal/app/system_update_handlers_test.go @@ -10,8 +10,10 @@ import ( "os" "path/filepath" "strings" + "sync" "sync/atomic" "testing" + "time" ) func TestSystemVersionAndUpdate(t *testing.T) { @@ -22,6 +24,11 @@ func TestSystemVersionAndUpdate(t *testing.T) { defer releaseServer.Close() var updateRequests atomic.Int32 + updateStarted := make(chan struct{}, 1) + releaseUpdate := make(chan struct{}) + var releaseUpdateOnce sync.Once + releaseBlockedUpdate := func() { releaseUpdateOnce.Do(func() { close(releaseUpdate) }) } + defer releaseBlockedUpdate() updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("update method = %s", r.Method) @@ -30,6 +37,8 @@ func TestSystemVersionAndUpdate(t *testing.T) { t.Errorf("authorization = %q", got) } updateRequests.Add(1) + updateStarted <- struct{}{} + <-releaseUpdate w.WriteHeader(http.StatusOK) })) defer updateServer.Close() @@ -66,12 +75,44 @@ func TestSystemVersionAndUpdate(t *testing.T) { t.Fatalf("unexpected version response: %+v", version) } - var update map[string]any - if code := admin.do("POST", "/api/admin/system/update", nil, &update); code != http.StatusAccepted { - t.Fatalf("update code=%d response=%v", code, update) + type updateResponse struct { + code int + err error } - if updateRequests.Load() != 1 { - t.Fatalf("update requests=%d", updateRequests.Load()) + response := make(chan updateResponse, 1) + go func() { + req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/admin/system/update", nil) + if err != nil { + response <- updateResponse{err: err} + return + } + req.AddCookie(admin.cookie) + resp, err := http.DefaultClient.Do(req) + if err != nil { + response <- updateResponse{err: err} + return + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + response <- updateResponse{code: resp.StatusCode} + }() + select { + case result := <-response: + if result.err != nil || result.code != http.StatusAccepted { + t.Fatalf("update response=%+v", result) + } + case <-time.After(2 * time.Second): + releaseBlockedUpdate() + t.Fatal("update response waited for container replacement") + } + select { + case <-updateStarted: + case <-time.After(2 * time.Second): + t.Fatal("scheduled update request did not start") + } + releaseBlockedUpdate() + if got := updateRequests.Load(); got != 1 { + t.Fatalf("update requests=%d", got) } backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db")) if err != nil || len(backups) != 1 { diff --git a/apps/web/src/components/system-version-dialog.tsx b/apps/web/src/components/system-version-dialog.tsx index 940acc8..fb45571 100644 --- a/apps/web/src/components/system-version-dialog.tsx +++ b/apps/web/src/components/system-version-dialog.tsx @@ -27,7 +27,19 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s const update = useMutation({ mutationFn: async () => { setUpdatePhase("starting") - const result = await api.updateSystem() + const targetVersion = version.data?.latestVersion + let result: Awaited> + try { + result = await api.updateSystem() + } catch (error) { + if (!targetVersion || !isUpdateConnectionInterruption(error)) throw error + result = { + ok: true, + currentVersion, + targetVersion, + message: "更新请求已发送,正在等待服务恢复", + } + } setUpdatePhase("restarting") await waitForUpdatedService(result.targetVersion) return result @@ -176,3 +188,8 @@ async function waitForUpdatedService(targetVersion: string) { function delay(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)) } + +function isUpdateConnectionInterruption(error: unknown) { + if (!(error instanceof Error)) return false + return /(?:502|503|504|网络请求失败|请求超时|failed to fetch|networkerror)/i.test(error.message) +}