fix(publish): prevent stuck publish queue and duplicate posting
Desktop Client Build / Resolve Build Metadata (push) Successful in 41s
Backend CI / Backend (push) Failing after 7m14s
Desktop Client Build / Build Desktop Client (push) Successful in 23m46s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 56s

The desktop publish queue could stall for a long time and end users assumed
the software was broken. Root cause: a hung adapter held its execution slot
with no wall-clock timeout while auto-renewing its lease forever, so the
server never reclaimed it and every queued task behind it stayed 等待发布.
Auto-recovery also risked silently re-posting a non-idempotent article.

Client (Electron):
- per-task wall-clock deadline + abort; progress-gated lease renewal that
  stops and aborts a stalled task instead of renewing it forever
- decouple the concurrency cap from CDP-induced CPU/memory pressure
  (admission gate instead of self-throttling collapse); 15s watchdog pump
- all adapter network I/O now has fetch timeouts and honors context.signal;
  bounded image-upload concurrency with per-image timeout
- surface live adapter progress, elapsed time, queue position and a
  working-vs-queued distinction in the publish view

Server (tenant-api):
- publish lease-recovery worker (every 3m) + supporting index: reclaim
  expired in_progress publish leases, requeue (<3 attempts) or terminal-fail
- 3-minute lease TTL with client-presence-gated extension; max 3 attempts

Idempotency (production-grade core):
- durable publish_submit_started_at marker, set before the irreversible
  platform submit POST; recovery and abort route a maybe-submitted task to
  unknown (manual reconcile, kept in the dedup set) instead of re-posting
- desktop UI requires explicit confirmation before retrying a possibly-
  already-published task

Verified: go build/vet/test (incl. resolvePublishRecoveryOutcome), vue-tsc,
vitest 141/141, gofmt all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 19:52:17 +08:00
parent 9d6181260a
commit fa51a3455f
41 changed files with 2124 additions and 358 deletions
+50 -25
View File
@@ -8,8 +8,10 @@ import { PUBLISH_IMAGE_FETCH_FAILED_MESSAGE } from '../../shared/publisher-error
import { resolveDesktopApiURL } from '../transport/api-client'
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
adapterFetch,
extractImageSources,
normalizeArticleHtml,
raceWithAbort,
sessionCookieHeader,
sessionCookieValue,
sessionFetchJson,
@@ -203,6 +205,7 @@ async function fetchCurrentAccount(context: PublishAdapterContext): Promise<Smzd
referer: `${SMZDM_ZHIYOU_ORIGIN}/user`,
},
},
{ signal: context.signal },
).catch(() => null)
const user = response?.data ?? response
@@ -257,10 +260,13 @@ async function getArticleId(context: PublishAdapterContext): Promise<string> {
throw new Error('smzdm_playwright_page_missing')
}
await page.goto(SMZDM_TOU_GAO_URL, {
waitUntil: 'domcontentloaded',
timeout: 45_000,
})
await raceWithAbort(
page.goto(SMZDM_TOU_GAO_URL, {
waitUntil: 'domcontentloaded',
timeout: 45_000,
}),
{ signal: context.signal, timeoutMs: 45_000 },
)
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => undefined)
const href = await page
@@ -320,9 +326,14 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
return candidates
}
async function fetchSmzdmImageBlob(sourceUrl: string): Promise<SmzdmImageBlob | null> {
async function fetchSmzdmImageBlob(
sourceUrl: string,
signal?: AbortSignal,
): Promise<SmzdmImageBlob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const response = await fetch(candidate).catch(() => null)
const response = await adapterFetch(candidate, {}, { signal, timeoutMs: 10_000 }).catch(
() => null,
)
if (!response?.ok) {
continue
}
@@ -386,7 +397,7 @@ async function uploadImage(
articleId: string,
cropCover: boolean,
): Promise<SmzdmUploadedImage | null> {
const image = await fetchSmzdmImageBlob(sourceUrl)
const image = await fetchSmzdmImageBlob(sourceUrl, context.signal)
if (!image) {
throw new Error(cropCover ? 'smzdm_cover_fetch_failed' : 'smzdm_image_fetch_failed')
}
@@ -406,6 +417,7 @@ async function uploadImage(
headers: await smzdmHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(
(error): SmzdmImageUploadResponse => ({
error_code: -1,
@@ -425,17 +437,22 @@ async function uploadImage(
const uploadedId = stringId(uploaded.data.id)
if (uploadedId) {
void sessionFetchJson(context.session, `${SMZDM_ORIGIN}/api/editor/image_add_time/record`, {
method: 'POST',
credentials: 'include',
headers: await smzdmHeaders(context, {
'content-type': 'application/x-www-form-urlencoded',
}),
body: new URLSearchParams({
article_id: articleId,
ids: uploadedId,
}),
}).catch(() => null)
void sessionFetchJson(
context.session,
`${SMZDM_ORIGIN}/api/editor/image_add_time/record`,
{
method: 'POST',
credentials: 'include',
headers: await smzdmHeaders(context, {
'content-type': 'application/x-www-form-urlencoded',
}),
body: new URLSearchParams({
article_id: articleId,
ids: uploadedId,
}),
},
{ signal: context.signal },
).catch(() => null)
}
if (!cropCover) {
@@ -460,6 +477,7 @@ async function uploadImage(
pic_url: uploaded.data.url,
}),
},
{ signal: context.signal },
).catch(() => null)
const crop = getSmzdmCoverCropRect(image.width, image.height)
@@ -497,6 +515,7 @@ async function uploadImage(
headers: await smzdmHeaders(context),
body: cropForm,
},
{ signal: context.signal },
).catch(
(error): SmzdmCropResponse => ({
error_code: -1,
@@ -570,14 +589,17 @@ async function calculateEditorSignature(
throw new Error('smzdm_playwright_page_missing')
}
await page.goto(`${SMZDM_ORIGIN}/edit/${encodeURIComponent(articleId)}`, {
waitUntil: 'domcontentloaded',
timeout: 45_000,
})
await raceWithAbort(
page.goto(`${SMZDM_ORIGIN}/edit/${encodeURIComponent(articleId)}`, {
waitUntil: 'domcontentloaded',
timeout: 45_000,
}),
{ signal: context.signal, timeoutMs: 45_000 },
)
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => undefined)
const textCount = await page
.evaluate((content) => {
const textCount = await raceWithAbort(
page.evaluate((content) => {
const editorNode = document.querySelector('.ProseMirror[contenteditable]') as
| (HTMLElement & {
editor?: {
@@ -600,7 +622,9 @@ async function calculateEditorSignature(
},
})
return editor.commands.getTextCount()
}, html)
}, html),
{ signal: context.signal, timeoutMs: 20_000 },
)
.catch(() => null)
const normalizedCount =
@@ -904,6 +928,7 @@ export const smzdmAdapter: PublishAdapter = {
}),
),
},
{ signal: context.signal },
).catch(
(requestError): SmzdmSubmitResponse => ({
error_code: -1,