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
+2 -1
View File
@@ -77,6 +77,7 @@ declare global {
params?: ListDesktopPublishTasksParams,
): Promise<DesktopPublishTaskListResponse>
retryPublishTask(taskId: string): Promise<CreatePublishJobResponse>
cancelPublishTask(taskId: string): Promise<DesktopTaskInfo>
openExternalUrl(url: string): Promise<null>
openSettingsWindow(): Promise<null>
getAppSettings(): Promise<DesktopAppSettings>
@@ -96,7 +97,7 @@ declare global {
): Promise<null>
onRuntimeInvalidated(
listener: (event: {
reason: 'account-health' | 'publish-task-lease'
reason: 'account-health' | 'publish-task-lease' | 'publish-task-progress'
at: number
accountId?: string
taskId?: string
@@ -59,6 +59,7 @@ export interface RuntimeTask {
status: 'queued' | 'in_progress' | 'succeeded' | 'failed' | 'unknown' | 'aborted'
routing: 'rabbitmq-primary' | 'db-recovery'
leaseExpiresAt: number | null
startedAt: number | null
updatedAt: number
summary: string
}
@@ -7,9 +7,10 @@ import {
ReloadOutlined,
RetweetOutlined,
SearchOutlined,
StopOutlined,
} from '@ant-design/icons-vue'
import type { DesktopPublishTaskListResponse, DesktopTaskInfo, JsonValue } from '@geo/shared-types'
import { notification } from 'ant-design-vue'
import { Modal, notification } from 'ant-design-vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { normalizePublisherErrorMessage } from '../../shared/publisher-errors'
@@ -27,6 +28,8 @@ const PAGE_SIZE = 10
const INLINE_ERROR_HEAD_CHARS = 48
const INLINE_ERROR_TAIL_CHARS = 10
const ACTIVE_TASK_POLL_INTERVAL_MS = 5_000
const DEFAULT_ESTIMATED_TASK_MS = 3 * 60_000
const PROGRESS_REFRESH_THROTTLE_MS = 3_000
const publishPlatformIndex: Record<string, (typeof desktopPublishMediaCatalog)[number]> =
Object.fromEntries(desktopPublishMediaCatalog.map((item) => [item.id, item]))
@@ -40,6 +43,8 @@ interface PublishTaskItem {
accountName: string
status: DesktopTaskInfo['status']
summary: string
queueSummary: string | null
running: boolean
attempts: number
leaseExpiresAt: number | null
createdAt: number
@@ -48,11 +53,13 @@ interface PublishTaskItem {
publishType: string | null
fallbackReason: string | null
errorMessage: string | null
submitUncertain: boolean
complianceBlockedRecordId: number | null
complianceBlockedAt: number | null
}
let refreshTimer: ReturnType<typeof setInterval> | null = null
let lastProgressRefreshAt = 0
let runtimeInvalidationUnsubscribe: (() => void) | null = null
function platformMeta(platform: string) {
@@ -114,8 +121,9 @@ function hasActivePublishingTask(page: DesktopPublishTaskListResponse | null): b
function statusTone(status: DesktopTaskInfo['status']): 'info' | 'warn' | 'success' | 'danger' {
switch (status) {
case 'queued':
case 'in_progress':
return 'info'
case 'queued':
return 'warn'
case 'succeeded':
return 'success'
@@ -311,6 +319,58 @@ function summaryForTask(
}
}
function elapsedTimeLabel(since: number, now = Date.now()): string {
const elapsedMs = Math.max(0, now - since)
if (elapsedMs < 60_000) {
return '不到 1 分钟'
}
if (elapsedMs < 60 * 60_000) {
return `${Math.max(1, Math.floor(elapsedMs / 60_000))} 分钟`
}
const hours = Math.max(1, Math.floor(elapsedMs / 3_600_000))
const minutes = Math.floor((elapsedMs % 3_600_000) / 60_000)
return minutes > 0 ? `${hours} 小时 ${minutes} 分钟` : `${hours} 小时`
}
function stageSummaryFromRuntime(raw: string | null | undefined): string | null {
const summary = raw?.trim()
if (!summary) {
return null
}
const stage = summary.match(/publish adapter progress:\s*([a-z0-9_.-]+)/i)?.[1] ?? summary
const stageKey = stage.trim().toLowerCase()
const stageMap: Record<string, string> = {
detect_login: '正在校验登录态',
normalize_html: '正在整理正文',
upload_content_images: '正在上传正文图片',
upload_cover: '正在上传封面图',
submit: '正在提交发布',
save_draft: '正在保存草稿',
create_draft: '正在创建草稿',
create_article_id: '正在打开投稿入口',
calculate_signature: '正在计算页面签名',
get_token: '正在获取发布凭证',
publish: '正在确认发表',
publish_draft: '正在发表草稿',
resolve_public_url: '正在确认公开链接',
resolve_article_url: '正在确认文章链接',
apply_subjects: '正在匹配推荐话题',
disable_mass_notification: '正在关闭群发通知',
continue_publish_without_notification: '正在继续确认发表',
}
const suffix = stageKey.split('.').pop() ?? stageKey
if (stageMap[stageKey]) {
return stageMap[stageKey]
}
if (stageMap[suffix]) {
return stageMap[suffix]
}
if (summary.startsWith('publish adapter progress:')) {
return `正在执行 ${stage}`
}
return summary
}
function parseTimestamp(value: string | null): number {
if (!value) {
return Date.now()
@@ -330,6 +390,7 @@ const error = ref<string | null>(null)
const consolePendingTaskId = ref<string | null>(null)
const copiedErrorTaskId = ref<string | null>(null)
const retryingTaskId = ref<string | null>(null)
const cancelingTaskId = ref<string | null>(null)
function notifySuccess(message: string) {
notification.success({
@@ -428,11 +489,22 @@ function bindPublishLeaseListener() {
}
runtimeInvalidationUnsubscribe = window.desktopBridge.app.onRuntimeInvalidated((event) => {
if (event.reason !== 'publish-task-lease') {
if (event.reason !== 'publish-task-lease' && event.reason !== 'publish-task-progress') {
return
}
void refreshTasks(1)
if (event.reason === 'publish-task-lease') {
void refreshTasks(1)
return
}
// publish-task-progress fires once per adapter stage; throttle the server-list
// refresh so the status badge / queue position stay fresh without hammering the API.
const now = Date.now()
if (now - lastProgressRefreshAt >= PROGRESS_REFRESH_THROTTLE_MS) {
lastProgressRefreshAt = now
void refreshTasks()
}
})
}
@@ -480,7 +552,25 @@ function canRetryTask(record: PublishTaskItem): boolean {
return record.status === 'failed' || record.status === 'aborted' || record.status === 'unknown'
}
function confirmUncertainRetry(record: PublishTaskItem): Promise<boolean> {
return new Promise((resolve) => {
Modal.confirm({
title: '该文章可能已发布',
content: `${record.title}」上次执行已进入提交阶段后中断,可能已经发布到平台。重新提交可能导致重复发文。请先到平台核实,确认尚未发布后再继续。`,
okText: '已核实,仍要重新提交',
okType: 'danger',
cancelText: '取消',
onOk: () => resolve(true),
onCancel: () => resolve(false),
})
})
}
async function retryTask(record: PublishTaskItem) {
if (record.submitUncertain && !(await confirmUncertainRetry(record))) {
return
}
retryingTaskId.value = record.id
try {
@@ -512,6 +602,27 @@ async function retryTask(record: PublishTaskItem) {
}
}
function canCancelTask(record: PublishTaskItem): boolean {
return record.status === 'queued'
}
async function cancelTask(record: PublishTaskItem) {
cancelingTaskId.value = record.id
try {
await window.desktopBridge.app.cancelPublishTask(record.id)
notifySuccess('已取消排队中的发布任务')
await refreshTasks(currentPage.value)
} catch (err) {
notifyActionError(
normalizeTaskErrorMessage(unwrapClientErrorMessage(err, '取消发布任务失败')) ??
'取消发布任务失败',
)
} finally {
cancelingTaskId.value = null
}
}
async function copyTextToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
@@ -592,9 +703,14 @@ const accountMap = computed(() => {
return new Map((snapshot.value?.accounts ?? []).map((item) => [item.id, item]))
})
const runtimeTaskMap = computed(() => {
return new Map((snapshot.value?.tasks ?? []).map((item) => [item.id, item]))
})
const publishTasks = computed<PublishTaskItem[]>(() =>
(taskPage.value?.items ?? []).map((task) => {
const normalizedStatus = normalizePublishTaskStatus(task.status)
const runtimeTask = runtimeTaskMap.value.get(task.id)
const payload = task.payload ?? {}
const result = task.result ?? {}
const taskError = task.error ?? {}
@@ -619,6 +735,44 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
const articleId =
extractNumber(payload.article_id) ??
extractNumber(nestedContentRef?.article_id ?? nestedContentRef?.id)
const pendingTasks =
taskPage.value?.items.filter((item) =>
isPendingStatus(normalizePublishTaskStatus(item.status)),
) ?? []
const queuedTasks = pendingTasks.filter(
(item) => normalizePublishTaskStatus(item.status) === 'queued',
)
const queuedIndex = queuedTasks.findIndex((item) => item.id === task.id)
const runningCount = pendingTasks.filter(
(item) => normalizePublishTaskStatus(item.status) === 'in_progress',
).length
// Queue position / ahead-count are only globally accurate on page 1, where all
// pending tasks (which the server always sorts first) are present. On later pages
// the per-page index would restart from 1 and mislead the user, so we drop the
// position there and keep only the always-correct queued elapsed time.
const positionIsAccurate = currentPage.value === 1
const queuePosition = queuedIndex >= 0 && positionIsAccurate ? queuedIndex + 1 : null
const aheadCount = queuePosition === null ? null : runningCount + Math.max(0, queuePosition - 1)
const estimatedMs =
aheadCount === null ? null : Math.max(0, aheadCount) * DEFAULT_ESTIMATED_TASK_MS
const queuedSummary =
normalizedStatus === 'queued'
? `${
queuePosition !== null ? `${queuePosition} 个,前面还有 ${aheadCount} 个 / ` : ''
}已排队 ${elapsedTimeLabel(parseTimestamp(task.created_at))}${
estimatedMs && estimatedMs > 0
? ` / 预计 ${Math.max(1, Math.ceil(estimatedMs / 60_000))} 分钟后执行`
: ''
}`
: null
const runningSummary =
normalizedStatus === 'in_progress'
? `已执行 ${elapsedTimeLabel(
runtimeTask?.startedAt ?? runtimeTask?.updatedAt ?? parseTimestamp(task.updated_at),
)}`
: null
const runtimeSummary =
normalizedStatus === 'in_progress' ? stageSummaryFromRuntime(runtimeTask?.summary) : null
return {
id: task.id,
@@ -628,7 +782,9 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
accountId: task.target_account_id,
accountName: accountNameMap.value.get(task.target_account_id) ?? '待同步账号',
status: normalizedStatus,
summary: summaryForTask(normalizedStatus, publishType, fallbackReason),
summary: runtimeSummary ?? summaryForTask(normalizedStatus, publishType, fallbackReason),
queueSummary: queuedSummary ?? runningSummary,
running: normalizedStatus === 'in_progress',
attempts: task.attempts,
leaseExpiresAt: task.lease_expires_at ? parseTimestamp(task.lease_expires_at) : null,
createdAt: parseTimestamp(task.created_at),
@@ -648,6 +804,7 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
extractString(taskError.code),
task.platform,
),
submitUncertain: task.status === 'unknown' && taskError.publish_submit_uncertain === true,
complianceBlockedRecordId,
complianceBlockedAt: task.compliance_blocked_at ? parseTimestamp(task.compliance_blocked_at) : null,
}
@@ -791,7 +948,13 @@ const tableScroll = { x: 1080 } as const
</template>
<template v-else-if="column.key === 'status'">
<StatusBadge :tone="statusToneForTask(record)" :label="statusLabelForTask(record)" />
<span class="status-cell">
<span v-if="record.running" class="status-spinner" aria-hidden="true" />
<StatusBadge
:tone="statusToneForTask(record)"
:label="statusLabelForTask(record)"
/>
</span>
</template>
<template v-else-if="column.key === 'meta'">
@@ -827,6 +990,9 @@ const tableScroll = { x: 1080 } as const
</a-popover>
</span>
<span v-else class="cell-sub strong-text">{{ record.summary }}</span>
<span v-if="record.queueSummary" class="cell-sub queue-text">
{{ record.queueSummary }}
</span>
<span class="cell-sub meta-footer">
<span class="attempt-chip">尝试 {{ record.attempts }} </span>
<template v-if="record.complianceBlockedRecordId">
@@ -885,7 +1051,11 @@ const tableScroll = { x: 1080 } as const
<template #icon><DesktopOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="canRetryTask(record)" title="重新提交发布任务" placement="top">
<a-tooltip
v-if="canRetryTask(record)"
:title="record.submitUncertain ? '该文章可能已发布,重试前请先核实平台侧结果' : '重新提交发布任务'"
placement="top"
>
<a-button
type="text"
class="icon-action-btn"
@@ -896,7 +1066,18 @@ const tableScroll = { x: 1080 } as const
<template #icon><RetweetOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="isPendingStatus(record.status)" title="排队中...">
<a-tooltip v-if="canCancelTask(record)" title="取消发布任务" placement="top">
<a-button
type="text"
class="icon-action-btn icon-action-btn--danger"
:loading="cancelingTaskId === record.id"
:aria-label="`取消发布任务:${record.title}`"
@click.stop="cancelTask(record)"
>
<template #icon><StopOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-else-if="isPendingStatus(record.status)" title="执行中">
<div class="action-placeholder-icon">
<ClockCircleOutlined />
</div>
@@ -1191,6 +1372,28 @@ const tableScroll = { x: 1080 } as const
width: 100%;
}
.status-cell {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.status-spinner {
width: 12px;
height: 12px;
flex: 0 0 auto;
border: 2px solid #bfdbfe;
border-top-color: #2563eb;
border-radius: 999px;
animation: publish-spin 0.8s linear infinite;
}
.queue-text {
color: #475569;
font-size: 12px;
}
.meta-footer {
display: inline-flex;
align-items: center;
@@ -1274,6 +1477,12 @@ const tableScroll = { x: 1080 } as const
border-color: #dbeafe;
}
.icon-action-btn--danger:hover {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
.action-placeholder-icon {
width: 28px;
height: 28px;
@@ -1292,6 +1501,12 @@ const tableScroll = { x: 1080 } as const
background: transparent;
}
@keyframes publish-spin {
to {
transform: rotate(360deg);
}
}
.feedback-banner {
padding: 12px 16px;
border-radius: 10px;