feat: add staged release updates
TallyNote release / linux-x64 (push) Failing after 2m51s

This commit is contained in:
Qiufeng
2026-09-01 14:46:32 +08:00
parent 4395317651
commit a268eb5fe9
18 changed files with 452 additions and 92 deletions
+78 -12
View File
@@ -23,6 +23,7 @@ import {
permanentDeleteSchema,
statusUpdateSchema,
updateApplySchema,
updateDownloadSchema,
versionSchema,
type AttachmentKind,
type ExpenseStatus,
@@ -94,7 +95,7 @@ const unsafeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const sessionCookie = "tally_session";
const csrfCookie = "tally_csrf";
type UpdateRateState = { checkedAt: number; appliedAt: number };
type UpdateRateState = { checkedAt: number; downloadedAt: number; appliedAt: number };
const updateRateStates = new WeakMap<DatabaseContext["sqlite"], Map<string, UpdateRateState>>();
function updateRateState(database: DatabaseContext["sqlite"], adminId: string): UpdateRateState {
@@ -105,7 +106,7 @@ function updateRateState(database: DatabaseContext["sqlite"], adminId: string):
}
let state = states.get(adminId);
if (!state) {
state = { checkedAt: 0, appliedAt: 0 };
state = { checkedAt: 0, downloadedAt: 0, appliedAt: 0 };
states.set(adminId, state);
}
return state;
@@ -115,13 +116,13 @@ function enforceUpdateCooldown(
database: DatabaseContext["sqlite"],
config: AppConfig,
adminId: string,
operation: "check" | "apply",
operation: "check" | "download" | "apply",
reply: FastifyReply,
): void {
const state = updateRateState(database, adminId);
const now = Date.now();
const previous = operation === "check" ? state.checkedAt : state.appliedAt;
const cooldown = operation === "check" ? config.updateCheckCooldownMs : config.updateApplyCooldownMs;
const previous = operation === "check" ? state.checkedAt : operation === "download" ? state.downloadedAt : state.appliedAt;
const cooldown = operation === "check" ? config.updateCheckCooldownMs : operation === "download" ? config.updateDownloadCooldownMs : config.updateApplyCooldownMs;
if (cooldown > 0 && previous > 0 && now - previous < cooldown) {
const retryAfter = Math.max(1, Math.ceil((cooldown - (now - previous)) / 1000));
reply.header("Retry-After", retryAfter);
@@ -130,6 +131,7 @@ function enforceUpdateCooldown(
: "更新操作过于频繁,请稍后再试");
}
if (operation === "check") state.checkedAt = now;
else if (operation === "download") state.downloadedAt = now;
else state.appliedAt = now;
}
@@ -932,9 +934,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
reply.header("Cache-Control", "no-store");
const cached = publicCheckFromCache(database.sqlite, config);
const row = database.sqlite.prepare(`
SELECT id, status, version, platform, asset_name AS assetName,
SELECT id, operation, status, version, platform, asset_name AS assetName,
size_bytes AS sizeBytes, error_message AS errorMessage,
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
requested_at AS applyQueuedAt
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
return {
@@ -988,6 +991,37 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
if (config.updateStrategy !== "systemd") {
throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
}
if (input.jobId) {
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", "更新任务尚未完成下载");
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();
const active = database.sqlite.transaction(() => {
const conflictRow = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) AND id<>? LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES, stagedJobId) as { id: string } | undefined;
if (conflictRow) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
const changed = database.sqlite.prepare("UPDATE update_jobs SET operation='apply', error_message=NULL, requested_at=?, request_id=?, updated_at=? WHERE id=? AND status='staged' AND operation='download'").run(now, request.id, now, stagedJobId);
if (changed.changes !== 1) throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候");
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: stagedJobId, after: { version: staged.version, staged: true } });
return { id: stagedJobId, now };
}).immediate();
applyAuditTarget = stagedJobId;
if (!staged.expectedSha256 || !/^[a-f0-9]{64}$/i.test(staged.expectedSha256)) {
database.sqlite.prepare("UPDATE update_jobs SET operation='download', error_message=?, updated_at=? WHERE id=? AND status='staged' AND operation='apply'").run("暂存更新缺少有效校验值", Date.now(), active.id);
throw new AppError(409, "UPDATE_NOT_VERIFIED", "暂存更新缺少有效校验值,请重新下载");
}
try {
await writeUpdateRequest(config, { jobId: active.id, operation: "apply", version: staged.version, metadataUrl: config.updateMetadataUrl, assetUrl: staged.assetUrl, assetName: staged.assetName ?? "staged", expectedSha256: staged.expectedSha256, requestedAt: active.now, currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir });
} catch {
database.sqlite.prepare("UPDATE update_jobs SET operation='download', error_message=?, updated_at=? WHERE id=? AND status='staged' AND operation='apply'").run("无法创建系统更新请求", Date.now(), active.id);
applyAuditRecorded = true;
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: active.id, outcome: "failure" });
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
}
reply.header("Cache-Control", "no-store");
return reply.code(202).send({ job: { id: active.id, status: "staged", version: staged.version, operation: "apply", applyQueuedAt: active.now, restartWindowSeconds: 30 } });
}
// Preserve the actionable in-progress response for duplicate clicks before
// applying the per-admin cooldown.
const activeBeforeCheck = database.sqlite.prepare(`
@@ -1055,8 +1089,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
return { id, now };
}).immediate();
applyAuditTarget = active.id;
const updateRequest: UpdateRequest = {
jobId: active.id,
const updateRequest: UpdateRequest = {
jobId: active.id,
operation: "apply",
version: requestedVersion,
metadataUrl: cached.metadataUrl,
assetUrl: releaseAsset.url,
@@ -1084,7 +1119,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
}
reply.header("Cache-Control", "no-store");
return reply.code(202).send({ job: { id: active.id, status: "queued", version: requestedVersion } });
return reply.code(202).send({ job: { id: active.id, status: "queued", version: requestedVersion, operation: "apply", applyQueuedAt: active.now, restartWindowSeconds: 30 } });
} catch (error) {
if (!applyAuditRecorded) {
writeAudit(database.sqlite, {
@@ -1101,12 +1136,43 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
}
});
app.post("/api/update/download", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
const input = updateDownloadSchema.parse(request.body);
if (config.updateStrategy !== "systemd") throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
if (active) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "download", reply);
const checked = await checkForUpdate(database.sqlite, config);
const version = input.version.replace(/^v/i, "");
if (!checked.latest || checked.latest.version !== version || !checked.latest.isNewer || !checked.latest.compatible || !checked.latest.integrityReady) throw new AppError(409, "UPDATE_NOT_AVAILABLE", "该版本已不可用,请重新检查更新");
const cached = readCachedRelease(database.sqlite, config);
const cachedAsset = cached?.asset;
if (!cached || !cachedAsset?.sha256 || cached.version !== version) throw new AppError(409, "UPDATE_NOT_VERIFIED", "发布文件缺少 SHA-256 校验值,无法更新");
const now = Date.now();
const id = randomUUID();
database.sqlite.transaction(() => {
const conflict = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
if (conflict) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
database.sqlite.prepare(`INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'queued', ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, request.auth!.admin.id, request.auth!.tokenHash, request.id, now, version, checked.platform.target, cached.metadataUrl, cachedAsset.name, cachedAsset.url, cachedAsset.sha256, now, now);
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.download_requested", targetType: "update", targetId: id, after: { version } });
}).immediate();
try {
await writeUpdateRequest(config, { jobId: id, operation: "download", version, metadataUrl: cached.metadataUrl, assetUrl: cachedAsset.url, assetName: cachedAsset.name, expectedSha256: cachedAsset.sha256, requestedAt: now, currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir });
} catch {
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=?").run("无法创建系统更新请求", Date.now(), id);
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
}
reply.header("Cache-Control", "no-store");
return reply.code(202).send({ job: { id, status: "queued", operation: "download", version } });
});
app.get("/api/update/jobs/:id", { preHandler: guard(database, config) }, async (request, reply) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
const row = database.sqlite.prepare(`
SELECT id, status, version, platform, asset_name AS assetName,
SELECT id, operation, status, version, platform, asset_name AS assetName,
size_bytes AS sizeBytes, error_message AS errorMessage,
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
requested_at AS applyQueuedAt
FROM update_jobs WHERE id=? AND admin_id=?
`).get(id, request.auth!.admin.id) as Record<string, unknown> | undefined;
if (!row) notFound("更新任务不存在");