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
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:
+2
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user