fix: make web updates survive container restart
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions

This commit is contained in:
zxyszx
2026-08-04 04:14:36 +08:00
parent e789cf9b14
commit 397ce51800
6 changed files with 102 additions and 14 deletions
+21
View File
@@ -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)
+2
View File
@@ -62,6 +62,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
### 命令行更新
+1 -1
View File
@@ -1 +1 @@
1.2.6
1.2.7
@@ -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 {
@@ -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 {
@@ -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<ReturnType<typeof api.updateSystem>>
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)
}