From efbd0e0d87f93804c25f3dadc758e437464663c3 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Sat, 5 Sep 2026 16:26:34 +0800 Subject: [PATCH] fix: make update center state consistent --- package.json | 2 +- server/app.ts | 9 ++ server/update-service.ts | 39 ++++++- tests/update-api.test.ts | 47 +++++++++ web-next/src/pages/update/UpdatePage.tsx | 127 ++++++++++++++++++----- 5 files changed, 195 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 6e4e08b..f1ec1d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.2.4", + "version": "1.2.5", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/server/app.ts b/server/app.ts index b9cacd3..dfd6fa8 100644 --- a/server/app.ts +++ b/server/app.ts @@ -54,6 +54,7 @@ import { validateNewPassword, verifyPassword, } from "./security.js"; +import { isNewerVersion } from "./update.js"; import { ACTIVE_UPDATE_STATUSES, checkForUpdate, @@ -1012,6 +1013,14 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { const stagedJobId = input.jobId; const staged = database.sqlite.prepare("SELECT id, status, operation, version, asset_url AS assetUrl, asset_name AS assetName, expected_sha256 AS expectedSha256 FROM update_jobs WHERE id=? AND admin_id=?").get(stagedJobId, request.auth!.admin.id) as { id: string; status: string; operation: string; version: string; assetUrl: string; assetName: string | null; expectedSha256: string | null } | undefined; if (!staged || staged.status !== "staged" || staged.version !== input.version.replace(/^v/i, "")) throw new AppError(409, "UPDATE_NOT_STAGED", "更新任务尚未完成下载"); + // A package may have been downloaded before the host was upgraded by + // another path. Never apply a staged archive that is no longer newer + // than the release currently serving traffic. + if (!isNewerVersion(config.appVersion, staged.version)) { + const now = Date.now(); + database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, completed_at=?, updated_at=? WHERE id=? AND status='staged'").run("暂存更新已过期,当前版本无需再次升级", now, now, stagedJobId); + throw new AppError(409, "UPDATE_NOT_AVAILABLE", "暂存更新已过期,请重新检查更新"); + } if (staged.operation === "apply") throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候"); enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "apply", reply); const now = Date.now(); diff --git a/server/update-service.ts b/server/update-service.ts index 1f692e8..e956ef8 100644 --- a/server/update-service.ts +++ b/server/update-service.ts @@ -487,12 +487,47 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config: let reconciled = 0; const reconciledIds = new Set(); for (const row of rows) { + // A fresh request/state marker means the privileged runner still owns the + // hand-off. Do not expire a staged/apply row while the runner is finishing + // a successful switch and finalization after a service restart. + const matchingFreshRequest = requestMarkerJobId === row.id && requestFresh; + const matchingFreshState = stateMarkerJobId === row.id && stateFresh; + // A staged archive is actionable only while it is strictly newer than the + // release currently serving requests. This can become false when an + // administrator upgrades the host by another path (or another operator + // completes the same release) before returning to this page. Treat the + // archive as an expired terminal task so it cannot keep blocking the + // queue or appear as an "apply" action for the current version. + if (row.status === "staged" && !isNewerVersion(config.appVersion, row.version) && !matchingFreshRequest && !matchingFreshState) { + const changed = database.transaction(() => { + const result = database.prepare(` + UPDATE update_jobs + SET status='failed', error_message=?, completed_at=?, updated_at=? + WHERE id=? AND status='staged' + `).run("暂存更新已过期,当前版本无需再次升级", now, now, row.id); + if (result.changes !== 1) return false; + writeAudit(database, { + requestId: row.requestId || randomUUID(), + actorAdminId: row.adminId, + action: "update.reconciled", + targetType: "update", + targetId: row.id, + outcome: "failure", + before: { status: row.status, operation: row.operation, version: row.version }, + after: { status: "failed", version: row.version, reason: "staged_version_not_newer" }, + }); + return true; + })(); + if (changed) { + reconciled += 1; + reconciledIds.add(row.id); + } + continue; + } // A request that never gets claimed by the root runner must not remain in // the UI as an endless "queued" task. Once the short hand-off window has // elapsed and no recovery marker exists, release the queue explicitly; // a fresh state marker proves that the runner has already claimed it. - const matchingFreshRequest = requestMarkerJobId === row.id && requestFresh; - const matchingFreshState = stateMarkerJobId === row.id && stateFresh; if (row.status === "queued" && typeof row.updatedAt === "number" && !matchingFreshState && now - row.updatedAt >= QUEUED_UPDATE_TIMEOUT_MS) { if (matchingFreshRequest) continue; const changed = database.transaction(() => { diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts index 8e8d6be..15ff974 100644 --- a/tests/update-api.test.ts +++ b/tests/update-api.test.ts @@ -155,6 +155,53 @@ describe("更新 API", () => { expect(checked.json().latest).toMatchObject({ version: "1.3.0", isNewer: true }); }); + it("不会应用已经等于当前版本的暂存更新", async () => { + const session = await login("update-staged-current"); + const admin = database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged-current") as { id: string }; + const now = Date.now(); + const stagedId = randomUUID(); + database.sqlite.prepare(` + INSERT INTO update_jobs( + id, admin_id, operation, status, version, platform, release_url, + asset_name, asset_url, expected_sha256, actual_sha256, download_path, + created_at, updated_at + ) VALUES (?, ?, 'download', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + stagedId, + admin.id, + config.appVersion, + detectPlatform().target, + config.updateMetadataUrl, + "current.tar.gz", + "https://updates.example/current.tar.gz", + "c".repeat(64), + "c".repeat(64), + path.join(config.dataDir, "staged-current"), + now, + now, + ); + + const apply = await app.inject({ + method: "POST", + url: "/api/update/apply", + headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, + payload: { jobId: stagedId, version: config.appVersion, confirm: true }, + }); + expect(apply.statusCode).toBe(409); + // Reconciliation expires same-version staged jobs before the apply route + // can consume them, so the public response is the generic not-staged + // conflict while the database records the precise expiry reason. + expect(apply.json().error.code).toBe("UPDATE_NOT_STAGED"); + expect(database.sqlite.prepare("SELECT status, error_message AS errorMessage FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ + status: "failed", + errorMessage: "暂存更新已过期,当前版本无需再次升级", + }); + + const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } }); + expect(status.statusCode).toBe(200); + expect(status.json().job).toBeNull(); + }); + it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => { const owner = await login("update-owner"); const other = await login("update-other"); diff --git a/web-next/src/pages/update/UpdatePage.tsx b/web-next/src/pages/update/UpdatePage.tsx index f7de0e4..6c902da 100644 --- a/web-next/src/pages/update/UpdatePage.tsx +++ b/web-next/src/pages/update/UpdatePage.tsx @@ -276,8 +276,8 @@ export default function UpdatePage({ }); }; - const load = async (showLoading = true) => { - if (loadInFlight.current) return; + const load = async (showLoading = true): Promise => { + if (loadInFlight.current) return null; loadInFlight.current = true; if (showLoading) setLoading(true); setError(""); @@ -285,18 +285,16 @@ export default function UpdatePage({ const res = await api("/api/update/status"); mergeInfo(res); updateInfoCache = res; + return res; } catch (e) { setError((e as Error).message); + return null; } finally { if (showLoading) setLoading(false); loadInFlight.current = false; } }; - useEffect(() => { - void load(); - }, []); - // Keep terminal jobs visible so operators can understand what happened and // recover without guessing. The polling effect below only polls active jobs. const job = info?.job ?? null; @@ -467,6 +465,24 @@ export default function UpdatePage({ } }; + useEffect(() => { + let disposed = false; + const bootstrap = async () => { + const snapshot = await load(); + if (disposed || !snapshot) return; + // Status may be satisfied from the release cache. Refresh it on entry + // only when the cached result is absent or older than one minute; this + // keeps the page current without turning navigation into a burst of + // rate-limited checks. + const checkedAt = snapshot.checkedAt || 0; + if (!checkedAt || !snapshot.latest || Date.now() - checkedAt > 60_000) await check(); + }; + void bootstrap(); + return () => { + disposed = true; + }; + }, []); + // Cancel queued job handler const cancelJob = async () => { if (cancelInFlight.current || !job?.id) return; @@ -539,9 +555,20 @@ export default function UpdatePage({ const latest = info?.latest; const notes = notesFor(latest); + const hasChecked = Boolean(info?.checkedAt); + const releaseState = checking + ? "checking" + : !hasChecked + ? "unverified" + : !latest + ? "unavailable" + : latest.isNewer + ? latest.compatible && latest.integrityReady ? "available" : "blocked" + : "up-to-date"; const canDownload = Boolean( info?.strategy === "systemd" && + !checking && latest?.isNewer && latest.compatible && latest.integrityReady && @@ -550,14 +577,19 @@ export default function UpdatePage({ const canApply = Boolean( info?.strategy === "systemd" && + !checking && job && job.status === "staged" && - job.operation === "download" + job.operation === "download" && + latest?.isNewer && + latest.version === job.version && + job.version !== info.currentVersion ); const hasActiveJob = Boolean( - job && activeStatuses.has(job.status) && job.status !== "staged" + job && activeStatuses.has(job.status) && !(job.status === "staged" && job.operation === "download") ); + const showJobDetails = hasActiveJob || canApply || Boolean(job?.status === "staged" && job.operation === "apply"); // Compute current pipeline step index (0: check, 1: download, 2: verify/stage, 3: apply/restart) const currentStep = useMemo(() => { @@ -614,7 +646,7 @@ export default function UpdatePage({ subtitle="管理系统版本升级、更新包完整性校验与安全热重启" actions={
- {hasActiveJob && ( + {showJobDetails && ( )} {canApply && ( @@ -791,7 +831,7 @@ export default function UpdatePage({ disabled={actionBusy} icon={} > - 更新包已就绪,立即应用 (v{latest.version}) + 立即应用并重启 v{latest.version} )} {hasActiveJob && ( @@ -804,9 +844,14 @@ export default function UpdatePage({ 查看当前升级进度 )} - {!latest.isNewer && !hasActiveJob && ( + {releaseState === "blocked" && !hasActiveJob && !canApply && ( + + )} + {releaseState === "up-to-date" && !hasActiveJob && !canApply && ( )}
@@ -814,10 +859,10 @@ export default function UpdatePage({ ) : (
- -

暂无待更新的版本信息,当前系统已是最新状态。

-
@@ -826,7 +871,7 @@ export default function UpdatePage({ {latest && notes && (
-

本次版本更新说明

+

发布说明

@@ -851,7 +896,7 @@ export default function UpdatePage({ {/* Unified Single Upgrade Modal (800px width on desktop) */}
@@ -1011,6 +1056,36 @@ export default function UpdatePage({
)} + {/* A staged archive can become obsolete when the host or release + metadata changes while this page is open. Keep the state visible + but remove the apply action; the server will reconcile it on the + next status request and the operator can perform a fresh check. */} + {job?.status === "staged" && job.operation === "download" && !canApply && ( +
+
暂存更新已失效
+

+ 这个更新包已不是当前可安全应用的版本,系统不会重复应用。请重新检查更新以获取最新发布信息。 +

+
+ +
+
+ )} + + {job?.status === "staged" && job.operation === "apply" && ( +
+
+
+ 应用请求已提交,正在等待更新服务接管 +
+

+ 系统正在准备备份与重启。页面会持续同步服务状态,请不要重复提交。 +

+
+ )} + {/* 场景 D: 数据快照备份中或服务重启中 (backing_up / applying) (Exact ASCII) */} {(job?.status === "backing_up" || job?.status === "applying") && (
@@ -1072,7 +1147,7 @@ export default function UpdatePage({ )} {/* Footer close button */} - {job?.status !== "staged" && job?.status !== "completed" && !confirmReadyToDownload && ( + {!(job?.status === "staged" && canApply) && job?.status !== "completed" && !confirmReadyToDownload && (