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:
@@ -29,3 +29,4 @@ apps/*/stats.html
|
||||
ops-api
|
||||
tmp/*
|
||||
*.pem
|
||||
outputs/*
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PUBLISH_IMAGE_FETCH_FAILED_MESSAGE } from '../../shared/publisher-error
|
||||
import { resolveDesktopApiURL } from '../transport/api-client'
|
||||
import type { PublishAdapter, PublishAdapterContext } from './base'
|
||||
import {
|
||||
adapterFetch,
|
||||
normalizeArticleHtml,
|
||||
sessionFetchJson,
|
||||
sessionFetchText,
|
||||
@@ -149,19 +150,25 @@ async function fetchAppInfo(context: PublishAdapterContext): Promise<BaijiahaoUs
|
||||
accept: 'application/json, text/plain, */*',
|
||||
}),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
return response?.data?.user ?? null
|
||||
}
|
||||
|
||||
async function getPublishToken(context: PublishAdapterContext): Promise<string> {
|
||||
const html = await sessionFetchText(context.session, BAIJIAHAO_EDIT_URL, {
|
||||
credentials: 'include',
|
||||
headers: baijiahaoHeaders({
|
||||
accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
}),
|
||||
})
|
||||
const html = await sessionFetchText(
|
||||
context.session,
|
||||
BAIJIAHAO_EDIT_URL,
|
||||
{
|
||||
credentials: 'include',
|
||||
headers: baijiahaoHeaders({
|
||||
accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
}),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
)
|
||||
|
||||
return /window\.__BJH__INIT__AUTH__\s*=\s*(["'])(.*?)\1/.exec(html)?.[2] ?? ''
|
||||
}
|
||||
@@ -172,7 +179,7 @@ async function uploadImage(
|
||||
appId: string,
|
||||
cropCover: boolean,
|
||||
): Promise<string | null> {
|
||||
const image = await fetchBaijiahaoImageBlob(sourceUrl)
|
||||
const image = await fetchBaijiahaoImageBlob(sourceUrl, context.signal)
|
||||
if (!image) {
|
||||
throw new Error(cropCover ? 'baijiahao_cover_fetch_failed' : 'baijiahao_image_fetch_failed')
|
||||
}
|
||||
@@ -196,6 +203,7 @@ async function uploadImage(
|
||||
headers: baijiahaoHeaders(),
|
||||
body: form,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch((error) => {
|
||||
throw new Error(
|
||||
error instanceof Error
|
||||
@@ -232,6 +240,7 @@ async function uploadImage(
|
||||
cutting_type: 'cover_image',
|
||||
}),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch((error) => {
|
||||
throw new Error(
|
||||
error instanceof Error
|
||||
@@ -279,9 +288,14 @@ function buildImageURLCandidates(sourceUrl: string): string[] {
|
||||
return candidates
|
||||
}
|
||||
|
||||
async function fetchBaijiahaoImageBlob(sourceUrl: string): Promise<BaijiahaoImageBlob | null> {
|
||||
async function fetchBaijiahaoImageBlob(
|
||||
sourceUrl: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BaijiahaoImageBlob | null> {
|
||||
for (const candidate of buildImageURLCandidates(sourceUrl)) {
|
||||
const response = await fetch(candidate).catch(() => null)
|
||||
const response = await adapterFetch(candidate, {}, { signal, timeoutMs: 10_000 }).catch(
|
||||
() => null,
|
||||
)
|
||||
if (!response?.ok) {
|
||||
continue
|
||||
}
|
||||
@@ -332,7 +346,7 @@ async function processContentImages(
|
||||
const processed = await uploadHtmlImages(html, async (sourceUrl) => {
|
||||
await waitForHumanPace(context.signal)
|
||||
return await uploadImage(context, sourceUrl, appId, false)
|
||||
})
|
||||
}, { signal: context.signal })
|
||||
|
||||
return processed.html
|
||||
}
|
||||
@@ -570,6 +584,7 @@ export const baijiahaoAdapter: PublishAdapter = {
|
||||
activity_list: buildActivityList(),
|
||||
}),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(
|
||||
(requestError): BaijiahaoPublishResponse => ({
|
||||
errno: -1,
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
fetchImageBlob,
|
||||
normalizeArticleHtml,
|
||||
@@ -117,11 +118,16 @@ async function fetchNav(context: PublishAdapterContext): Promise<BilibiliNavResp
|
||||
credentials: 'include',
|
||||
headers: await bilibiliHeaders(context),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
}
|
||||
|
||||
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(input, init)
|
||||
async function mainFetchJson<T>(
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<T> {
|
||||
const response = await adapterFetch(input, init, { signal: options.signal })
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `request_failed_${response.status}`)
|
||||
@@ -230,9 +236,9 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
|
||||
return candidates
|
||||
}
|
||||
|
||||
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
|
||||
async function fetchAssetBlob(sourceUrl: string, signal?: AbortSignal): Promise<Blob | null> {
|
||||
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
|
||||
const blob = await fetchImageBlob(candidate)
|
||||
const blob = await fetchImageBlob(candidate, { signal })
|
||||
if (blob) {
|
||||
return blob
|
||||
}
|
||||
@@ -244,7 +250,7 @@ async function uploadImage(
|
||||
context: PublishAdapterContext,
|
||||
sourceUrl: string,
|
||||
): Promise<string | null> {
|
||||
const blob = await fetchAssetBlob(sourceUrl)
|
||||
const blob = await fetchAssetBlob(sourceUrl, context.signal)
|
||||
if (!blob) {
|
||||
throw new Error('bilibili_image_fetch_failed')
|
||||
}
|
||||
@@ -268,6 +274,7 @@ async function uploadImage(
|
||||
headers: await bilibiliHeaders(context),
|
||||
body: form,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(
|
||||
(error): BilibiliUploadResponse => ({
|
||||
code: -1,
|
||||
@@ -284,8 +291,10 @@ async function uploadImage(
|
||||
}
|
||||
|
||||
async function processContentImages(context: PublishAdapterContext, html: string): Promise<string> {
|
||||
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
|
||||
uploadImage(context, sourceUrl),
|
||||
const processed = await uploadHtmlImages(
|
||||
html,
|
||||
async (sourceUrl) => uploadImage(context, sourceUrl),
|
||||
{ signal: context.signal },
|
||||
)
|
||||
return processed.html
|
||||
}
|
||||
@@ -547,6 +556,7 @@ async function findPublishedArticle(
|
||||
credentials: 'include',
|
||||
headers: await bilibiliHeaders(context),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
const items = response?.data?.items ?? []
|
||||
|
||||
@@ -4,6 +4,26 @@ import { marked } from 'marked'
|
||||
|
||||
import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from '../user-agent'
|
||||
|
||||
const defaultFetchTimeoutMs = 15_000
|
||||
const defaultImageFetchTimeoutMs = 10_000
|
||||
const defaultHtmlImageUploadTimeoutMs = 30_000
|
||||
const defaultHtmlImageUploadConcurrency = 3
|
||||
|
||||
export interface AdapterFetchOptions {
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface UploadHtmlImagesOptions extends AdapterFetchOptions {
|
||||
imageTimeoutMs?: number
|
||||
concurrency?: number
|
||||
}
|
||||
|
||||
export interface AbortablePromiseOptions extends AdapterFetchOptions {
|
||||
abortErrorMessage?: string
|
||||
timeoutErrorMessage?: string
|
||||
}
|
||||
|
||||
export function normalizeText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
@@ -76,13 +96,133 @@ export function extractImageSources(html: string): string[] {
|
||||
return [...sources]
|
||||
}
|
||||
|
||||
export async function fetchImageBlob(sourceUrl: string): Promise<Blob | null> {
|
||||
export function mergeAbortSignals(
|
||||
signals: Array<AbortSignal | null | undefined>,
|
||||
): AbortSignal | undefined {
|
||||
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal))
|
||||
if (!activeSignals.length) {
|
||||
return undefined
|
||||
}
|
||||
const anyAbortSignal = (AbortSignal as typeof AbortSignal & {
|
||||
any?: (signals: AbortSignal[]) => AbortSignal
|
||||
}).any
|
||||
if (typeof anyAbortSignal === 'function') {
|
||||
return anyAbortSignal(activeSignals)
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const abort = (signal: AbortSignal) => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(signal.reason)
|
||||
}
|
||||
}
|
||||
for (const signal of activeSignals) {
|
||||
if (signal.aborted) {
|
||||
abort(signal)
|
||||
break
|
||||
}
|
||||
signal.addEventListener('abort', () => abort(signal), { once: true })
|
||||
}
|
||||
return controller.signal
|
||||
}
|
||||
|
||||
export function timeoutSignal(timeoutMs = defaultFetchTimeoutMs): AbortSignal {
|
||||
const timeoutAbortSignal = (AbortSignal as typeof AbortSignal & {
|
||||
timeout?: (timeoutMs: number) => AbortSignal
|
||||
}).timeout
|
||||
if (typeof timeoutAbortSignal === 'function') {
|
||||
return timeoutAbortSignal(timeoutMs)
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => controller.abort(new Error('adapter_fetch_timeout')), timeoutMs)
|
||||
return controller.signal
|
||||
}
|
||||
|
||||
export function buildAdapterAbortSignal(options: AdapterFetchOptions = {}): AbortSignal {
|
||||
return mergeAbortSignals([
|
||||
options.signal,
|
||||
timeoutSignal(options.timeoutMs ?? defaultFetchTimeoutMs),
|
||||
]) as AbortSignal
|
||||
}
|
||||
|
||||
export function withRequestTimeout(init: RequestInit = {}, options: AdapterFetchOptions = {}): RequestInit {
|
||||
return {
|
||||
...init,
|
||||
signal: mergeAbortSignals([
|
||||
init.signal,
|
||||
options.signal,
|
||||
timeoutSignal(options.timeoutMs ?? defaultFetchTimeoutMs),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
export async function adapterFetch(
|
||||
input: string | URL | Request,
|
||||
init: RequestInit = {},
|
||||
options: AdapterFetchOptions = {},
|
||||
): Promise<Response> {
|
||||
return fetch(input, withRequestTimeout(init, options))
|
||||
}
|
||||
|
||||
export async function raceWithAbort<T>(
|
||||
promise: Promise<T>,
|
||||
options: AbortablePromiseOptions = {},
|
||||
): Promise<T> {
|
||||
const timeoutMs = options.timeoutMs ?? defaultFetchTimeoutMs
|
||||
const abortErrorMessage = options.abortErrorMessage ?? 'adapter_aborted'
|
||||
const timeoutErrorMessage = options.timeoutErrorMessage ?? 'adapter_fetch_timeout'
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error(abortErrorMessage)
|
||||
}
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle)
|
||||
timeoutHandle = null
|
||||
}
|
||||
options.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
const onAbort = () => {
|
||||
cleanup()
|
||||
reject(new Error(abortErrorMessage))
|
||||
}
|
||||
|
||||
timeoutHandle = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(timeoutErrorMessage))
|
||||
}, timeoutMs)
|
||||
options.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
promise.then(
|
||||
(value) => {
|
||||
cleanup()
|
||||
resolve(value)
|
||||
},
|
||||
(error) => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchImageBlob(
|
||||
sourceUrl: string,
|
||||
options: AdapterFetchOptions = {},
|
||||
): Promise<Blob | null> {
|
||||
const normalizedUrl = normalizeRemoteUrl(sourceUrl)
|
||||
if (!normalizedUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await fetch(normalizedUrl).catch(() => null)
|
||||
const response = await adapterFetch(normalizedUrl, {}, {
|
||||
timeoutMs: options.timeoutMs ?? defaultImageFetchTimeoutMs,
|
||||
signal: options.signal,
|
||||
}).catch(() => null)
|
||||
if (!response?.ok) {
|
||||
return null
|
||||
}
|
||||
@@ -98,6 +238,7 @@ export async function fetchImageBlob(sourceUrl: string): Promise<Blob | null> {
|
||||
export async function uploadHtmlImages(
|
||||
html: string,
|
||||
uploader: (sourceUrl: string) => Promise<string | null>,
|
||||
options: UploadHtmlImagesOptions = {},
|
||||
): Promise<{ html: string; uploaded: Map<string, string> }> {
|
||||
const sources = extractImageSources(html)
|
||||
if (!sources.length) {
|
||||
@@ -108,13 +249,68 @@ export async function uploadHtmlImages(
|
||||
}
|
||||
|
||||
const uploaded = new Map<string, string>()
|
||||
for (const source of sources) {
|
||||
const target = await uploader(source)
|
||||
const overallSignal = buildAdapterAbortSignal({
|
||||
signal: options.signal,
|
||||
timeoutMs: options.timeoutMs ?? defaultHtmlImageUploadTimeoutMs,
|
||||
})
|
||||
const concurrency = Math.max(
|
||||
1,
|
||||
Math.min(options.concurrency ?? defaultHtmlImageUploadConcurrency, sources.length),
|
||||
)
|
||||
let cursor = 0
|
||||
|
||||
const uploadOne = async (source: string) => {
|
||||
if (overallSignal.aborted) {
|
||||
throw new Error('adapter_aborted')
|
||||
}
|
||||
|
||||
const target = await Promise.race([
|
||||
uploader(source),
|
||||
new Promise<null>((resolve) => {
|
||||
const signal = timeoutSignal(options.imageTimeoutMs ?? defaultImageFetchTimeoutMs)
|
||||
if (signal.aborted) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
signal.addEventListener('abort', () => resolve(null), { once: true })
|
||||
}),
|
||||
]).catch(() => null)
|
||||
if (target) {
|
||||
uploaded.set(source, target)
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: concurrency }, async () => {
|
||||
// Stop pulling new images once the overall signal aborts so abandoned workers
|
||||
// don't keep firing fresh network requests after the caller has given up.
|
||||
while (cursor < sources.length && !overallSignal.aborted) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
await uploadOne(sources[index])
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.all(workers),
|
||||
new Promise<never>((_, reject) => {
|
||||
if (overallSignal.aborted) {
|
||||
reject(new Error('adapter_aborted'))
|
||||
return
|
||||
}
|
||||
overallSignal.addEventListener('abort', () => reject(new Error('adapter_aborted')), {
|
||||
once: true,
|
||||
})
|
||||
}),
|
||||
])
|
||||
} catch {
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error('adapter_aborted')
|
||||
}
|
||||
// Best-effort image replacement: keep successfully uploaded images and leave
|
||||
// slow or broken image URLs untouched instead of blocking the whole article.
|
||||
}
|
||||
|
||||
let next = html
|
||||
for (const [from, to] of uploaded.entries()) {
|
||||
next = next.split(from).join(to)
|
||||
@@ -127,6 +323,7 @@ export async function sessionFetchText(
|
||||
session: Session,
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
options: AdapterFetchOptions = {},
|
||||
): Promise<string> {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (!headers.has('user-agent')) {
|
||||
@@ -139,6 +336,11 @@ export async function sessionFetchText(
|
||||
const response = await session.fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
signal: mergeAbortSignals([
|
||||
init?.signal,
|
||||
options.signal,
|
||||
timeoutSignal(options.timeoutMs ?? defaultFetchTimeoutMs),
|
||||
]),
|
||||
})
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
@@ -151,8 +353,9 @@ export async function sessionFetchJson<T>(
|
||||
session: Session,
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
options: AdapterFetchOptions = {},
|
||||
): Promise<T> {
|
||||
const text = await sessionFetchText(session, input, init)
|
||||
const text = await sessionFetchText(session, input, init, options)
|
||||
if (!text) {
|
||||
return {} as T
|
||||
}
|
||||
@@ -169,7 +372,8 @@ export interface PageFetchInit {
|
||||
export async function sessionCookieFetchJson<T>(
|
||||
session: Session,
|
||||
url: string,
|
||||
init?: { method?: string; headers?: Record<string, string>; body?: string },
|
||||
init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal },
|
||||
options: AdapterFetchOptions = {},
|
||||
): Promise<T | null> {
|
||||
let parsedURL: URL
|
||||
try {
|
||||
@@ -208,6 +412,11 @@ export async function sessionCookieFetchJson<T>(
|
||||
method: init?.method ?? 'GET',
|
||||
headers,
|
||||
body: init?.body,
|
||||
signal: mergeAbortSignals([
|
||||
init?.signal,
|
||||
options.signal,
|
||||
timeoutSignal(options.timeoutMs ?? defaultFetchTimeoutMs),
|
||||
]),
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
@@ -238,6 +447,7 @@ export async function pageFetchJson<T>(
|
||||
webContents: WebContents,
|
||||
url: string,
|
||||
init?: PageFetchInit,
|
||||
options: AdapterFetchOptions = {},
|
||||
): Promise<T | null> {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return null
|
||||
@@ -249,8 +459,11 @@ export async function pageFetchJson<T>(
|
||||
}
|
||||
|
||||
const script = `(async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), ${JSON.stringify(options.timeoutMs ?? defaultFetchTimeoutMs)});
|
||||
try {
|
||||
const response = await fetch(${JSON.stringify(url)}, ${JSON.stringify(effectiveInit)});
|
||||
const init = ${JSON.stringify(effectiveInit)};
|
||||
const response = await fetch(${JSON.stringify(url)}, { ...init, signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
return JSON.stringify({ __ok: false, status: response.status });
|
||||
}
|
||||
@@ -261,12 +474,17 @@ export async function pageFetchJson<T>(
|
||||
return JSON.stringify({ __ok: true, body: text });
|
||||
} catch (error) {
|
||||
return JSON.stringify({ __ok: false, error: String(error && error.message || error) });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})()`
|
||||
|
||||
let serialized: string
|
||||
try {
|
||||
serialized = (await webContents.executeJavaScript(script, true)) as string
|
||||
serialized = (await raceWithAbort(webContents.executeJavaScript(script, true) as Promise<string>, {
|
||||
signal: options.signal,
|
||||
timeoutMs: options.timeoutMs,
|
||||
})) as string
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -331,7 +549,11 @@ export async function ensureViewLoaded(
|
||||
return
|
||||
}
|
||||
|
||||
await view.webContents.loadURL(url)
|
||||
await raceWithAbort(view.webContents.loadURL(url), {
|
||||
signal,
|
||||
timeoutMs: 45_000,
|
||||
timeoutErrorMessage: 'adapter_view_load_timeout',
|
||||
})
|
||||
await waitForLoadStop(view, signal)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
ensureViewLoaded,
|
||||
extractImageSources,
|
||||
normalizeArticleHtml,
|
||||
raceWithAbort,
|
||||
sessionFetchJson,
|
||||
sessionFetchText,
|
||||
} from './common'
|
||||
import { countDongchediContentWordCount, prepareDongchediArticleHtml } from './dongchedi-content'
|
||||
import { cropImageAssetBlob, fetchImageAssetBlob, type ImageAssetBlob } from './media-image'
|
||||
@@ -276,6 +278,9 @@ async function dongchediPageFetchJson<T>(
|
||||
},
|
||||
): Promise<T> {
|
||||
await ensureDongchediEditorContext(context)
|
||||
if (context.signal.aborted) {
|
||||
throw new Error('adapter_aborted')
|
||||
}
|
||||
|
||||
const effectiveInit = {
|
||||
credentials: 'include' as const,
|
||||
@@ -284,20 +289,30 @@ async function dongchediPageFetchJson<T>(
|
||||
|
||||
const page = context.playwright?.page
|
||||
if (page && !page.isClosed()) {
|
||||
const envelope = await page.evaluate(
|
||||
const envelope = await raceWithAbort(
|
||||
page.evaluate(
|
||||
async ({ fetchInput, fetchInit }) => {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||
try {
|
||||
const response = await fetch(fetchInput, fetchInit)
|
||||
const response = await fetch(fetchInput, {
|
||||
...fetchInit,
|
||||
signal: controller.signal,
|
||||
})
|
||||
const text = await response.text().catch(() => '')
|
||||
return { ok: response.ok, status: response.status, body: text }
|
||||
} catch (error) {
|
||||
return { ok: false, error: String((error && (error as Error).message) || error) }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
},
|
||||
{
|
||||
fetchInput: input,
|
||||
fetchInit: effectiveInit,
|
||||
},
|
||||
),
|
||||
{ signal: context.signal, timeoutMs: 35_000 },
|
||||
)
|
||||
return parseDongchediFetchEnvelope<T>(envelope)
|
||||
}
|
||||
@@ -308,17 +323,24 @@ async function dongchediPageFetchJson<T>(
|
||||
}
|
||||
|
||||
const script = `(async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||
try {
|
||||
const init = ${JSON.stringify(effectiveInit)};
|
||||
const response = await fetch(${JSON.stringify(input)}, init);
|
||||
const response = await fetch(${JSON.stringify(input)}, { ...init, signal: controller.signal });
|
||||
const text = await response.text().catch(() => "");
|
||||
return JSON.stringify({ ok: response.ok, status: response.status, body: text });
|
||||
} catch (error) {
|
||||
return JSON.stringify({ ok: false, error: String(error && error.message || error) });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})()`
|
||||
|
||||
const serialized = await webContents.executeJavaScript(script, true)
|
||||
const serialized = await raceWithAbort(webContents.executeJavaScript(script, true) as Promise<string>, {
|
||||
signal: context.signal,
|
||||
timeoutMs: 35_000,
|
||||
})
|
||||
if (typeof serialized !== 'string' || !serialized) {
|
||||
throw new Error('dongchedi_page_fetch_failed')
|
||||
}
|
||||
@@ -378,13 +400,20 @@ function parseDongchediFetchEnvelope<T>(envelope: {
|
||||
}
|
||||
|
||||
async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise<string | null> {
|
||||
if (context.signal.aborted) {
|
||||
throw new Error('adapter_aborted')
|
||||
}
|
||||
|
||||
const page = context.playwright?.page
|
||||
if (page && !page.isClosed()) {
|
||||
const token = await page.evaluate(async (url) => {
|
||||
const token = await raceWithAbort(page.evaluate(async (url) => {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 15_000)
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'x-secsdk-csrf-request': '1',
|
||||
'x-secsdk-csrf-version': '1.2.10',
|
||||
@@ -393,8 +422,13 @@ async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise<
|
||||
return response.headers.get('x-ware-csrf-token') || ''
|
||||
} catch {
|
||||
return ''
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, `${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`)
|
||||
}, `${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`), {
|
||||
signal: context.signal,
|
||||
timeoutMs: 18_000,
|
||||
})
|
||||
const parts = token.split(',')
|
||||
return parts.length >= 2 && parts[1] ? parts[1] : null
|
||||
}
|
||||
@@ -405,10 +439,13 @@ async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise<
|
||||
}
|
||||
|
||||
const script = `(async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
try {
|
||||
const response = await fetch(${JSON.stringify(`${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`)}, {
|
||||
method: "HEAD",
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"x-secsdk-csrf-request": "1",
|
||||
"x-secsdk-csrf-version": "1.2.10"
|
||||
@@ -417,10 +454,15 @@ async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise<
|
||||
return JSON.stringify({ token: response.headers.get("x-ware-csrf-token") || "" });
|
||||
} catch {
|
||||
return JSON.stringify({ token: "" });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})()`
|
||||
|
||||
const serialized = await webContents.executeJavaScript(script, true).catch(() => '')
|
||||
const serialized = await raceWithAbort(
|
||||
webContents.executeJavaScript(script, true) as Promise<string>,
|
||||
{ signal: context.signal, timeoutMs: 18_000 },
|
||||
).catch(() => '')
|
||||
if (typeof serialized !== 'string' || !serialized) {
|
||||
return null
|
||||
}
|
||||
@@ -440,12 +482,7 @@ async function imagexFetchText(
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
): Promise<string> {
|
||||
const response = await context.session.fetch(input, init)
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `request_failed_${response.status}`)
|
||||
}
|
||||
return text
|
||||
return await sessionFetchText(context.session, input, init, { signal: context.signal })
|
||||
}
|
||||
|
||||
async function imagexFetchJson<T>(
|
||||
@@ -471,6 +508,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<DongchediAc
|
||||
{
|
||||
credentials: 'include',
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
const user = response?.data
|
||||
@@ -595,6 +633,7 @@ async function fetchUploadToken(
|
||||
{
|
||||
credentials: 'include',
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
const token = response?.data?.token
|
||||
@@ -625,7 +664,7 @@ async function uploadImage(
|
||||
sourceUrl: string,
|
||||
ratio?: number,
|
||||
): Promise<DongchediUploadedImage | null> {
|
||||
const source = await fetchImageAssetBlob(sourceUrl)
|
||||
const source = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
|
||||
if (!source) {
|
||||
throw new Error(ratio ? 'dongchedi_cover_fetch_failed' : 'dongchedi_image_fetch_failed')
|
||||
}
|
||||
@@ -680,11 +719,15 @@ async function uploadImage(
|
||||
'content-crc32': crc32(new Uint8Array(buffer)),
|
||||
authorization: storeInfo.Auth,
|
||||
})
|
||||
const putResponse = await context.session.fetch(`https://${uploadHost}/${storeUri}`, {
|
||||
method: 'PUT',
|
||||
headers: putHeaders,
|
||||
body: image.blob,
|
||||
})
|
||||
const putResponse = await raceWithAbort(
|
||||
context.session.fetch(`https://${uploadHost}/${storeUri}`, {
|
||||
method: 'PUT',
|
||||
headers: putHeaders,
|
||||
body: image.blob,
|
||||
signal: context.signal,
|
||||
}),
|
||||
{ signal: context.signal, timeoutMs: 30_000 },
|
||||
)
|
||||
if (!putResponse.ok) {
|
||||
const text = await putResponse.text().catch(() => '')
|
||||
throw new Error(text || `dongchedi_image_tos_upload_failed_${putResponse.status}`)
|
||||
@@ -729,6 +772,7 @@ async function uploadImage(
|
||||
credentials: 'include',
|
||||
body: `img_uris=${storeUri}&img_url_type=2&img_param=noop&img_format=image`,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
const imageUrl = imageUrlResponse?.data?.img_url_map?.[storeUri]?.main_url?.trim() || ''
|
||||
@@ -765,6 +809,7 @@ async function spiceImage(
|
||||
imageUrl,
|
||||
}),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
return response?.data ?? null
|
||||
|
||||
@@ -41,9 +41,9 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
|
||||
return candidates
|
||||
}
|
||||
|
||||
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
|
||||
async function fetchAssetBlob(sourceUrl: string, signal?: AbortSignal): Promise<Blob | null> {
|
||||
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
|
||||
const blob = await fetchImageBlob(candidate)
|
||||
const blob = await fetchImageBlob(candidate, { signal })
|
||||
if (blob) {
|
||||
return blob
|
||||
}
|
||||
@@ -83,8 +83,9 @@ export const jianshuAdapter: PublishAdapter = {
|
||||
coverAssetUrl: context.article.cover_asset_url,
|
||||
},
|
||||
{
|
||||
fetchJson: (input, init) => sessionFetchJson(context.session, input, init),
|
||||
fetchImageBlob: fetchAssetBlob,
|
||||
fetchJson: (input, init) =>
|
||||
sessionFetchJson(context.session, input, init, { signal: context.signal }),
|
||||
fetchImageBlob: (sourceUrl) => fetchAssetBlob(sourceUrl, context.signal),
|
||||
reportProgress(stage) {
|
||||
context.reportProgress(`jianshu.${stage}`)
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { JsonValue } from '@geo/shared-types'
|
||||
import { resolveDesktopApiURL } from '../transport/api-client'
|
||||
import type { PublishAdapter, PublishAdapterContext } from './base'
|
||||
import {
|
||||
adapterFetch,
|
||||
fetchImageBlob,
|
||||
normalizeArticleHtml,
|
||||
sessionCookieHeader,
|
||||
@@ -180,6 +181,7 @@ async function fetchProfile(context: PublishAdapterContext): Promise<JuejinProfi
|
||||
}),
|
||||
body: '{}',
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
return response?.data?.bui_user ?? null
|
||||
@@ -233,6 +235,7 @@ function shouldSkipImageSource(source: string): boolean {
|
||||
async function uploadMarkdownImages(
|
||||
markdown: string,
|
||||
uploader: (sourceUrl: string) => Promise<string | null>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const sources = extractMarkdownImageSources(markdown)
|
||||
if (!sources.length) {
|
||||
@@ -241,10 +244,27 @@ async function uploadMarkdownImages(
|
||||
|
||||
const uploaded = new Map<string, string>()
|
||||
for (const source of sources) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error('adapter_aborted')
|
||||
}
|
||||
if (shouldSkipImageSource(source)) {
|
||||
continue
|
||||
}
|
||||
const target = await uploader(source)
|
||||
let imageTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const target = await Promise.race([
|
||||
uploader(source),
|
||||
new Promise<null>((resolve) => {
|
||||
imageTimeout = setTimeout(() => resolve(null), 10_000)
|
||||
signal?.addEventListener('abort', () => resolve(null), { once: true })
|
||||
}),
|
||||
])
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
if (imageTimeout) {
|
||||
clearTimeout(imageTimeout)
|
||||
imageTimeout = null
|
||||
}
|
||||
})
|
||||
if (target) {
|
||||
uploaded.set(source, target)
|
||||
}
|
||||
@@ -386,9 +406,9 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
|
||||
return candidates
|
||||
}
|
||||
|
||||
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
|
||||
async function fetchAssetBlob(sourceUrl: string, signal?: AbortSignal): Promise<Blob | null> {
|
||||
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
|
||||
const blob = await fetchImageBlob(candidate)
|
||||
const blob = await fetchImageBlob(candidate, { signal })
|
||||
if (blob) {
|
||||
return blob
|
||||
}
|
||||
@@ -504,8 +524,12 @@ function crc32Table(): Uint32Array {
|
||||
return table
|
||||
}
|
||||
|
||||
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(input, init)
|
||||
async function mainFetchJson<T>(
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<T> {
|
||||
const response = await adapterFetch(input, init, { signal: options.signal })
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `request_failed_${response.status}`)
|
||||
@@ -532,6 +556,7 @@ async function fetchImageXToken(
|
||||
credentials: 'include',
|
||||
headers: await juejinHeaders(context),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
)
|
||||
|
||||
if (response.err_no && response.err_no !== 0) {
|
||||
@@ -550,7 +575,7 @@ async function fetchImageXToken(
|
||||
}
|
||||
}
|
||||
|
||||
async function applyImageUpload(token: JuejinImageXToken) {
|
||||
async function applyImageUpload(token: JuejinImageXToken, signal?: AbortSignal) {
|
||||
const url = `${JUEJIN_IMAGEX_ORIGIN}/?Action=ApplyImageUpload&Version=2018-08-01&ServiceId=${JUEJIN_IMAGEX_SERVICE_ID}`
|
||||
const signedHeaders = signAWS4({
|
||||
method: 'GET',
|
||||
@@ -559,10 +584,14 @@ async function applyImageUpload(token: JuejinImageXToken) {
|
||||
secretAccessKey: token.secretAccessKey,
|
||||
securityToken: token.sessionToken,
|
||||
})
|
||||
const response = await mainFetchJson<JuejinImageXApplyUploadResponse>(url, {
|
||||
method: 'GET',
|
||||
headers: signedHeaders,
|
||||
})
|
||||
const response = await mainFetchJson<JuejinImageXApplyUploadResponse>(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: signedHeaders,
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
const uploadAddress = response.Result?.UploadAddress
|
||||
const storeInfo = uploadAddress?.StoreInfos?.[0]
|
||||
const uploadHost = uploadAddress?.UploadHosts?.[0]
|
||||
@@ -580,24 +609,33 @@ async function applyImageUpload(token: JuejinImageXToken) {
|
||||
async function uploadToTOS(
|
||||
upload: Awaited<ReturnType<typeof applyImageUpload>>,
|
||||
blob: Blob,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const buffer = Buffer.from(await blob.arrayBuffer())
|
||||
const response = await fetch(`https://${upload.uploadHost}/${upload.storeUri}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
authorization: upload.auth,
|
||||
'content-type': blob.type || 'application/octet-stream',
|
||||
'content-crc32': crc32(new Uint8Array(buffer)),
|
||||
const response = await adapterFetch(
|
||||
`https://${upload.uploadHost}/${upload.storeUri}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
authorization: upload.auth,
|
||||
'content-type': blob.type || 'application/octet-stream',
|
||||
'content-crc32': crc32(new Uint8Array(buffer)),
|
||||
},
|
||||
body: blob,
|
||||
},
|
||||
body: blob,
|
||||
})
|
||||
{ signal },
|
||||
)
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new Error(text || `juejin_image_tos_upload_failed_${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function commitImageUpload(token: JuejinImageXToken, sessionKey: string): Promise<void> {
|
||||
async function commitImageUpload(
|
||||
token: JuejinImageXToken,
|
||||
sessionKey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const url = `${JUEJIN_IMAGEX_ORIGIN}/?Action=CommitImageUpload&Version=2018-08-01&SessionKey=${encodeURIComponent(sessionKey)}&ServiceId=${JUEJIN_IMAGEX_SERVICE_ID}`
|
||||
const signedHeaders = signAWS4({
|
||||
method: 'POST',
|
||||
@@ -606,13 +644,17 @@ async function commitImageUpload(token: JuejinImageXToken, sessionKey: string):
|
||||
secretAccessKey: token.secretAccessKey,
|
||||
securityToken: token.sessionToken,
|
||||
})
|
||||
const response = await mainFetchJson<JuejinImageXCommitUploadResponse>(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...signedHeaders,
|
||||
'content-length': '0',
|
||||
const response = await mainFetchJson<JuejinImageXCommitUploadResponse>(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...signedHeaders,
|
||||
'content-length': '0',
|
||||
},
|
||||
},
|
||||
})
|
||||
{ signal },
|
||||
)
|
||||
if (!response.Result) {
|
||||
throw new Error('juejin_image_commit_upload_failed')
|
||||
}
|
||||
@@ -631,6 +673,7 @@ async function resolveImageURL(
|
||||
credentials: 'include',
|
||||
headers: await juejinHeaders(context),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
)
|
||||
if (response.err_no && response.err_no !== 0) {
|
||||
throw new Error(response.err_msg || 'juejin_image_url_failed')
|
||||
@@ -647,15 +690,15 @@ async function uploadImage(
|
||||
uuid: string,
|
||||
sourceUrl: string,
|
||||
): Promise<string | null> {
|
||||
const blob = await fetchAssetBlob(sourceUrl)
|
||||
const blob = await fetchAssetBlob(sourceUrl, context.signal)
|
||||
if (!blob || !blob.type.startsWith('image/')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const token = await fetchImageXToken(context, uuid)
|
||||
const upload = await applyImageUpload(token)
|
||||
await uploadToTOS(upload, blob)
|
||||
await commitImageUpload(token, upload.sessionKey)
|
||||
const upload = await applyImageUpload(token, context.signal)
|
||||
await uploadToTOS(upload, blob, context.signal)
|
||||
await commitImageUpload(token, upload.sessionKey, context.signal)
|
||||
return await resolveImageURL(context, uuid, upload.storeUri)
|
||||
}
|
||||
|
||||
@@ -697,6 +740,7 @@ async function createDraft(
|
||||
pics: [],
|
||||
}),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
)
|
||||
|
||||
if (response.err_no && response.err_no !== 0) {
|
||||
@@ -892,6 +936,7 @@ async function findPublishedArticle(
|
||||
page_no: 1,
|
||||
}),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
const article = extractArticleFromListResponse(response, draftId)
|
||||
@@ -1012,6 +1057,7 @@ export const juejinAdapter: PublishAdapter = {
|
||||
markdown = await uploadMarkdownImages(
|
||||
markdown,
|
||||
async (sourceUrl) => await uploadImage(context, uuid, sourceUrl).catch(() => null),
|
||||
context.signal,
|
||||
)
|
||||
|
||||
context.reportProgress('juejin.create_draft')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Buffer } from 'node:buffer'
|
||||
import { nativeImage } from 'electron'
|
||||
|
||||
import { fetchDesktopApiURL, resolveDesktopApiURL } from '../transport/api-client'
|
||||
import { adapterFetch, mergeAbortSignals, timeoutSignal, type AdapterFetchOptions } from './common'
|
||||
|
||||
export interface ImageAssetBlob {
|
||||
blob: Blob
|
||||
@@ -97,10 +98,20 @@ function fileNameForImageType(sourceType: string): string {
|
||||
return 'image.png'
|
||||
}
|
||||
|
||||
export async function fetchImageAssetBlob(sourceUrl: string): Promise<ImageAssetBlob | null> {
|
||||
export async function fetchImageAssetBlob(
|
||||
sourceUrl: string,
|
||||
options: AdapterFetchOptions = {},
|
||||
): Promise<ImageAssetBlob | null> {
|
||||
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
|
||||
const response = await (
|
||||
shouldUseDesktopAssetFetch(candidate) ? fetchDesktopApiURL(candidate) : fetch(candidate)
|
||||
shouldUseDesktopAssetFetch(candidate)
|
||||
? fetchDesktopApiURL(candidate, {
|
||||
signal: mergeAbortSignals([
|
||||
options.signal,
|
||||
timeoutSignal(options.timeoutMs ?? 10_000),
|
||||
]),
|
||||
})
|
||||
: adapterFetch(candidate, {}, { timeoutMs: options.timeoutMs ?? 10_000, signal: options.signal })
|
||||
).catch(() => null)
|
||||
if (!response?.ok) {
|
||||
continue
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { resolveDesktopApiURL } from '../transport/api-client'
|
||||
import type { PublishAdapter, PublishAdapterContext } from './base'
|
||||
import {
|
||||
adapterFetch,
|
||||
fetchImageBlob,
|
||||
normalizeArticleHtml,
|
||||
sessionCookieHeader,
|
||||
@@ -171,13 +172,18 @@ async function fetchBasicInfo(context: PublishAdapterContext): Promise<QiehaoCpI
|
||||
credentials: 'include',
|
||||
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
return response?.data?.cpInfo ?? null
|
||||
}
|
||||
|
||||
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(input, init)
|
||||
async function mainFetchJson<T>(
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<T> {
|
||||
const response = await adapterFetch(input, init, { signal: options.signal })
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `request_failed_${response.status}`)
|
||||
@@ -224,9 +230,9 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
|
||||
return candidates
|
||||
}
|
||||
|
||||
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
|
||||
async function fetchAssetBlob(sourceUrl: string, signal?: AbortSignal): Promise<Blob | null> {
|
||||
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
|
||||
const blob = await fetchImageBlob(candidate)
|
||||
const blob = await fetchImageBlob(candidate, { signal })
|
||||
if (blob) {
|
||||
return blob
|
||||
}
|
||||
@@ -239,7 +245,7 @@ async function uploadImage(
|
||||
sourceUrl: string,
|
||||
includeCoverMeta: boolean,
|
||||
): Promise<string | null> {
|
||||
const blob = await fetchAssetBlob(sourceUrl)
|
||||
const blob = await fetchAssetBlob(sourceUrl, context.signal)
|
||||
if (!blob) {
|
||||
throw new Error(includeCoverMeta ? 'qiehao_cover_fetch_failed' : 'qiehao_image_fetch_failed')
|
||||
}
|
||||
@@ -259,6 +265,7 @@ async function uploadImage(
|
||||
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN, {}, 'image.om.qq.com'),
|
||||
body: form,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(
|
||||
(error): QiehaoUploadStepOneResponse => ({
|
||||
msg: error instanceof Error ? error.message : 'qiehao_image_upload_failed',
|
||||
@@ -306,6 +313,7 @@ async function uploadImage(
|
||||
relogin: 1,
|
||||
}),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(
|
||||
(error): QiehaoUploadStepTwoResponse => ({
|
||||
msg: error instanceof Error ? error.message : 'qiehao_cover_upload_failed',
|
||||
@@ -341,6 +349,7 @@ async function findArticleUrl(
|
||||
credentials: 'include',
|
||||
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
const article = list?.data?.articles?.find(
|
||||
@@ -502,8 +511,10 @@ export const qiehaoAdapter: PublishAdapter = {
|
||||
}
|
||||
|
||||
context.reportProgress('qiehao.upload_content_images')
|
||||
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
|
||||
uploadImage(context, sourceUrl, false),
|
||||
const processed = await uploadHtmlImages(
|
||||
html,
|
||||
async (sourceUrl) => uploadImage(context, sourceUrl, false),
|
||||
{ signal: context.signal },
|
||||
)
|
||||
|
||||
const coverImages: string[] = []
|
||||
@@ -524,27 +535,32 @@ export const qiehaoAdapter: PublishAdapter = {
|
||||
: `${QIEHAO_ORIGIN}/marticlepublish/omSave`
|
||||
|
||||
context.reportProgress('qiehao.submit')
|
||||
const response = await sessionFetchJson<QiehaoPublishResponse>(context.session, endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN, {
|
||||
'content-type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
title: context.article.title?.trim() || '未命名文章',
|
||||
content: processed.html,
|
||||
imgurl_ext: JSON.stringify(coverImages),
|
||||
cover_type: '1',
|
||||
imgurlsrc: 'custom',
|
||||
orignal: 0,
|
||||
user_original: 0,
|
||||
mediaId,
|
||||
needpub: 1,
|
||||
type: 0,
|
||||
relogin: 1,
|
||||
resource_aigc_mark_info: JSON.stringify(resourceMarkInfo),
|
||||
}),
|
||||
}).catch(
|
||||
const response = await sessionFetchJson<QiehaoPublishResponse>(
|
||||
context.session,
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN, {
|
||||
'content-type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
title: context.article.title?.trim() || '未命名文章',
|
||||
content: processed.html,
|
||||
imgurl_ext: JSON.stringify(coverImages),
|
||||
cover_type: '1',
|
||||
imgurlsrc: 'custom',
|
||||
orignal: 0,
|
||||
user_original: 0,
|
||||
mediaId,
|
||||
needpub: 1,
|
||||
type: 0,
|
||||
relogin: 1,
|
||||
resource_aigc_mark_info: JSON.stringify(resourceMarkInfo),
|
||||
}),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(
|
||||
(requestError): QiehaoPublishResponse => ({
|
||||
code: -1,
|
||||
msg: requestError instanceof Error ? requestError.message : 'qiehao_publish_failed',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -141,6 +141,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<SohuAccount
|
||||
credentials: 'include',
|
||||
headers: await sohuHeaders(context),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
const account = accountFromRaw(registerInfo?.data?.account)
|
||||
@@ -157,6 +158,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<SohuAccount
|
||||
credentials: 'include',
|
||||
headers: await sohuHeaders(context),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
for (const group of list?.data?.data ?? []) {
|
||||
@@ -176,7 +178,7 @@ async function uploadImage(
|
||||
accountId: string,
|
||||
sourceUrl: string,
|
||||
): Promise<string | null> {
|
||||
const image = await fetchImageAssetBlob(sourceUrl)
|
||||
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
|
||||
if (!image) {
|
||||
return null
|
||||
}
|
||||
@@ -188,14 +190,19 @@ async function uploadImage(
|
||||
const url = new URL(`${SOHU_ORIGIN}/commons/front/outerUpload/image/file`)
|
||||
url.searchParams.set('accountId', accountId)
|
||||
|
||||
const response = await sessionFetchJson<SohuUploadResponse>(context.session, url.toString(), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await sohuHeaders(context, {
|
||||
'sp-cm': await sohuSpCm(context),
|
||||
}),
|
||||
body: form,
|
||||
}).catch(() => null)
|
||||
const response = await sessionFetchJson<SohuUploadResponse>(
|
||||
context.session,
|
||||
url.toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await sohuHeaders(context, {
|
||||
'sp-cm': await sohuSpCm(context),
|
||||
}),
|
||||
body: form,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
return response?.url?.trim() || null
|
||||
}
|
||||
@@ -225,16 +232,21 @@ async function submitArticle(
|
||||
infoResource: 0,
|
||||
}
|
||||
|
||||
const response = await sessionFetchJson<SohuSubmitResponse>(context.session, url.toString(), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await sohuHeaders(context, {
|
||||
'content-type': 'application/json',
|
||||
'dv-id': generateDeviceId(),
|
||||
'sp-cm': await sohuSpCm(context),
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
}).catch(
|
||||
const response = await sessionFetchJson<SohuSubmitResponse>(
|
||||
context.session,
|
||||
url.toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await sohuHeaders(context, {
|
||||
'content-type': 'application/json',
|
||||
'dv-id': generateDeviceId(),
|
||||
'sp-cm': await sohuSpCm(context),
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(
|
||||
(error): SohuSubmitResponse => ({
|
||||
code: -1,
|
||||
msg: error instanceof Error ? error.message : 'sohuhao_submit_failed',
|
||||
@@ -355,8 +367,10 @@ export const sohuhaoAdapter: PublishAdapter = {
|
||||
}
|
||||
|
||||
context.reportProgress('sohuhao.upload_content_images')
|
||||
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
|
||||
uploadImage(context, account.id, sourceUrl),
|
||||
const processed = await uploadHtmlImages(
|
||||
html,
|
||||
async (sourceUrl) => uploadImage(context, account.id, sourceUrl),
|
||||
{ signal: context.signal },
|
||||
)
|
||||
const publishType = resolvePublishType(payload)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('toutiao adapter', () => {
|
||||
|
||||
expect(bodyBlob?.type).toBe('image/png')
|
||||
expect(coverBlob?.type).toBe('image/png')
|
||||
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/body-token')
|
||||
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/cover-token')
|
||||
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/body-token', expect.anything())
|
||||
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/cover-token', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,11 @@ import type { PublishAdapter } from './base'
|
||||
import { normalizeArticleHtml, sessionFetchJson, uploadHtmlImages } from './common'
|
||||
import { fetchImageAssetBlob } from './media-image'
|
||||
|
||||
async function fetchToutiaoImageBlob(sourceUrl: string): Promise<Blob | null> {
|
||||
return (await fetchImageAssetBlob(sourceUrl))?.blob ?? null
|
||||
async function fetchToutiaoImageBlob(
|
||||
sourceUrl: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Blob | null> {
|
||||
return (await fetchImageAssetBlob(sourceUrl, { signal }))?.blob ?? null
|
||||
}
|
||||
|
||||
function buildResultPayload(
|
||||
@@ -40,9 +43,11 @@ export const toutiaoAdapter: PublishAdapter = {
|
||||
publishType: 'publish',
|
||||
},
|
||||
{
|
||||
fetchJson: (input, init) => sessionFetchJson(context.session, input, init),
|
||||
fetchImageBlob: fetchToutiaoImageBlob,
|
||||
uploadHtmlImages,
|
||||
fetchJson: (input, init) =>
|
||||
sessionFetchJson(context.session, input, init, { signal: context.signal }),
|
||||
fetchImageBlob: (sourceUrl) => fetchToutiaoImageBlob(sourceUrl, context.signal),
|
||||
uploadHtmlImages: (sourceHtml, uploader) =>
|
||||
uploadHtmlImages(sourceHtml, uploader, { signal: context.signal }),
|
||||
reportProgress(stage) {
|
||||
context.reportProgress(`toutiao.${stage}`)
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { PublishAdapter, PublishAdapterContext } from './base'
|
||||
import {
|
||||
extractImageSources,
|
||||
raceWithAbort,
|
||||
sessionCookieHeader,
|
||||
sessionFetchJson,
|
||||
uploadHtmlImages,
|
||||
@@ -180,6 +181,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<WangyiAccou
|
||||
credentials: 'include',
|
||||
headers: await wangyiHeaders(context),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
const media = response?.data?.mediaInfo
|
||||
@@ -213,6 +215,7 @@ async function fetchPublishDetail(
|
||||
credentials: 'include',
|
||||
headers: await wangyiHeaders(context),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
if (String(response?.code) !== '1' || !response?.data) {
|
||||
@@ -235,12 +238,13 @@ async function getPublishToken(context: PublishAdapterContext): Promise<string>
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await page
|
||||
.goto(WANGYI_HOME_URL, {
|
||||
await raceWithAbort(
|
||||
page.goto(WANGYI_HOME_URL, {
|
||||
waitUntil: attempt === 0 ? 'domcontentloaded' : 'load',
|
||||
timeout: 20_000,
|
||||
})
|
||||
.catch(() => null)
|
||||
}),
|
||||
{ signal: context.signal, timeoutMs: 20_000 },
|
||||
).catch(() => null)
|
||||
await page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => null)
|
||||
if (attempt > 0) {
|
||||
await sleep(800, context.signal)
|
||||
@@ -445,7 +449,7 @@ async function uploadImage(
|
||||
account: WangyiAccount,
|
||||
sourceUrl: string,
|
||||
): Promise<string | null> {
|
||||
const image = await fetchImageAssetBlob(sourceUrl)
|
||||
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
|
||||
if (!image) {
|
||||
return null
|
||||
}
|
||||
@@ -460,15 +464,20 @@ async function uploadImage(
|
||||
url.searchParams.set('wemediaId', account.wemediaId)
|
||||
url.searchParams.set('realUserId', account.realUserId)
|
||||
|
||||
const response = await sessionFetchJson<WangyiUploadResponse>(context.session, url.toString(), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await wangyiHeaders(context, {
|
||||
'x-b3-sampled': '1',
|
||||
'x-b3-spanid': '0',
|
||||
}),
|
||||
body: form,
|
||||
}).catch(() => null)
|
||||
const response = await sessionFetchJson<WangyiUploadResponse>(
|
||||
context.session,
|
||||
url.toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await wangyiHeaders(context, {
|
||||
'x-b3-sampled': '1',
|
||||
'x-b3-spanid': '0',
|
||||
}),
|
||||
body: form,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
return response?.data?.url?.trim() || null
|
||||
}
|
||||
@@ -544,16 +553,21 @@ async function saveDraftWithSession(
|
||||
url: string,
|
||||
body: URLSearchParams,
|
||||
): Promise<WangyiDraftResponse> {
|
||||
return await sessionFetchJson<WangyiDraftResponse>(context.session, url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await wangyiHeaders(context, {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'x-b3-sampled': '1',
|
||||
'x-b3-spanid': '0',
|
||||
}),
|
||||
body,
|
||||
}).catch(
|
||||
return await sessionFetchJson<WangyiDraftResponse>(
|
||||
context.session,
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await wangyiHeaders(context, {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'x-b3-sampled': '1',
|
||||
'x-b3-spanid': '0',
|
||||
}),
|
||||
body,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(
|
||||
(error): WangyiDraftResponse => ({
|
||||
code: -1,
|
||||
message: error instanceof Error ? error.message : 'wangyihao_draft_save_failed',
|
||||
@@ -571,24 +585,34 @@ async function saveDraftWithPage(
|
||||
return null
|
||||
}
|
||||
|
||||
const serialized = await page.evaluate(
|
||||
async ({ requestUrl, requestBody }) => {
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'x-b3-sampled': '1',
|
||||
'x-b3-spanid': '0',
|
||||
},
|
||||
body: requestBody,
|
||||
})
|
||||
return await response.text()
|
||||
},
|
||||
{
|
||||
requestUrl: url,
|
||||
requestBody: body.toString(),
|
||||
},
|
||||
const serialized = await raceWithAbort(
|
||||
page.evaluate(
|
||||
async ({ requestUrl, requestBody }) => {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||
try {
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'x-b3-sampled': '1',
|
||||
'x-b3-spanid': '0',
|
||||
},
|
||||
body: requestBody,
|
||||
})
|
||||
return await response.text()
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
},
|
||||
{
|
||||
requestUrl: url,
|
||||
requestBody: body.toString(),
|
||||
},
|
||||
),
|
||||
{ signal: context.signal, timeoutMs: 35_000 },
|
||||
)
|
||||
|
||||
if (!serialized) {
|
||||
@@ -835,9 +859,12 @@ async function publishDraft(context: PublishAdapterContext, draftId: string): Pr
|
||||
}
|
||||
|
||||
const draftUrl = `${WANGYI_CONSOLE_ORIGIN}/subscribe_v4/index.html#/article-publish/${encodeURIComponent(draftId)}?option=editDraft`
|
||||
await page.goto(draftUrl, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
})
|
||||
await raceWithAbort(
|
||||
page.goto(draftUrl, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
}),
|
||||
{ signal: context.signal, timeoutMs: 45_000 },
|
||||
)
|
||||
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => null)
|
||||
await waitForWangyiEditorReady(page)
|
||||
|
||||
@@ -997,8 +1024,10 @@ export const wangyihaoAdapter: PublishAdapter = {
|
||||
}
|
||||
|
||||
context.reportProgress('wangyihao.upload_content_images')
|
||||
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
|
||||
uploadImage(context, account, sourceUrl),
|
||||
const processed = await uploadHtmlImages(
|
||||
html,
|
||||
async (sourceUrl) => uploadImage(context, account, sourceUrl),
|
||||
{ signal: context.signal },
|
||||
)
|
||||
|
||||
context.reportProgress('wangyihao.save_draft')
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { PublishAdapter, PublishAdapterContext } from './base'
|
||||
import {
|
||||
normalizeArticleHtml,
|
||||
normalizeRemoteUrl,
|
||||
raceWithAbort,
|
||||
sessionFetchJson,
|
||||
sessionFetchText,
|
||||
uploadHtmlImages,
|
||||
@@ -198,12 +199,17 @@ function parseMeta(html: string): WeixinMeta | null {
|
||||
}
|
||||
|
||||
async function fetchMeta(context: PublishAdapterContext): Promise<WeixinMeta | null> {
|
||||
const html = await sessionFetchText(context.session, WEIXIN_HOME_URL, {
|
||||
credentials: 'include',
|
||||
headers: await weixinHeaders(context, {
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
}),
|
||||
}).catch(() => '')
|
||||
const html = await sessionFetchText(
|
||||
context.session,
|
||||
WEIXIN_HOME_URL,
|
||||
{
|
||||
credentials: 'include',
|
||||
headers: await weixinHeaders(context, {
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
}),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => '')
|
||||
|
||||
return html ? parseMeta(html) : null
|
||||
}
|
||||
@@ -268,7 +274,7 @@ async function uploadImage(
|
||||
return normalized
|
||||
}
|
||||
|
||||
const image = await fetchImageAssetBlob(sourceUrl)
|
||||
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
|
||||
if (!image) {
|
||||
return null
|
||||
}
|
||||
@@ -297,12 +303,17 @@ async function uploadImage(
|
||||
url.searchParams.set('seq', String(Date.now()))
|
||||
url.searchParams.set('t', String(Math.random()))
|
||||
|
||||
const response = await sessionFetchJson<WeixinUploadResponse>(context.session, url.toString(), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await weixinHeaders(context),
|
||||
body: form,
|
||||
}).catch(() => null)
|
||||
const response = await sessionFetchJson<WeixinUploadResponse>(
|
||||
context.session,
|
||||
url.toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await weixinHeaders(context),
|
||||
body: form,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
if (response?.base_resp?.err_msg && response.base_resp.err_msg !== 'ok') {
|
||||
const message = response.base_resp.err_msg
|
||||
@@ -402,7 +413,7 @@ async function uploadCover(
|
||||
meta: WeixinMeta,
|
||||
sourceUrl: string,
|
||||
): Promise<WeixinCover | null> {
|
||||
const source = await fetchImageAssetBlob(sourceUrl)
|
||||
const source = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
@@ -412,7 +423,7 @@ async function uploadCover(
|
||||
return null
|
||||
}
|
||||
|
||||
const cdnImage = await fetchImageAssetBlob(cdnUrl).catch(() => null)
|
||||
const cdnImage = await fetchImageAssetBlob(cdnUrl, { signal: context.signal }).catch(() => null)
|
||||
const width = cdnImage?.width || source.width
|
||||
const height = cdnImage?.height || source.height
|
||||
const rect235 = cropPercent(width, height, 2.35)
|
||||
@@ -426,14 +437,19 @@ async function uploadCover(
|
||||
|
||||
const url = new URL(`${WEIXIN_ORIGIN}/cgi-bin/cropimage`)
|
||||
url.searchParams.set('action', 'crop_multi')
|
||||
const response = await sessionFetchJson<WeixinCropResponse>(context.session, url.toString(), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await weixinHeaders(context, {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
}),
|
||||
body,
|
||||
}).catch(() => null)
|
||||
const response = await sessionFetchJson<WeixinCropResponse>(
|
||||
context.session,
|
||||
url.toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: await weixinHeaders(context, {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
}),
|
||||
body,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
return response ? coverFromCropResponse(cdnUrl, body, response) : null
|
||||
}
|
||||
@@ -1014,10 +1030,12 @@ function isWeixinPublishNetworkResponse(response: PlaywrightResponse): boolean {
|
||||
async function waitForWeixinPublishNetworkResponse(
|
||||
page: PlaywrightPage,
|
||||
timeoutMs = WEIXIN_PUBLISH_RESPONSE_TIMEOUT_MS,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WeixinOperateResponse | null> {
|
||||
const response = await page
|
||||
.waitForResponse(isWeixinPublishNetworkResponse, { timeout: timeoutMs })
|
||||
.catch(() => null)
|
||||
const response = await raceWithAbort(
|
||||
page.waitForResponse(isWeixinPublishNetworkResponse, { timeout: timeoutMs }),
|
||||
{ signal, timeoutMs: timeoutMs + 500 },
|
||||
).catch(() => null)
|
||||
if (!response) {
|
||||
return null
|
||||
}
|
||||
@@ -1028,21 +1046,23 @@ async function waitForWeixinPublishNetworkResponse(
|
||||
return parseWeixinOperateResponseText(text)
|
||||
}
|
||||
|
||||
async function waitForWeixinEditorReady(page: PlaywrightPage): Promise<void> {
|
||||
await page
|
||||
.waitForLoadState('domcontentloaded', { timeout: WEIXIN_EDITOR_READY_TIMEOUT_MS })
|
||||
.catch(() => null)
|
||||
async function waitForWeixinEditorReady(page: PlaywrightPage, signal?: AbortSignal): Promise<void> {
|
||||
await raceWithAbort(
|
||||
page.waitForLoadState('domcontentloaded', { timeout: WEIXIN_EDITOR_READY_TIMEOUT_MS }),
|
||||
{ signal, timeoutMs: WEIXIN_EDITOR_READY_TIMEOUT_MS + 500 },
|
||||
).catch(() => null)
|
||||
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null)
|
||||
await page
|
||||
.waitForFunction(
|
||||
await raceWithAbort(
|
||||
page.waitForFunction(
|
||||
() => {
|
||||
const text = document.body?.innerText || ''
|
||||
return /发表|发布|草稿|图文|登录/.test(text)
|
||||
},
|
||||
undefined,
|
||||
{ timeout: WEIXIN_EDITOR_READY_TIMEOUT_MS },
|
||||
)
|
||||
.catch(() => null)
|
||||
),
|
||||
{ signal, timeoutMs: WEIXIN_EDITOR_READY_TIMEOUT_MS + 500 },
|
||||
).catch(() => null)
|
||||
}
|
||||
|
||||
async function readWeixinPageMessage(
|
||||
@@ -1606,7 +1626,11 @@ async function completeWeixinPublishConfirmation(
|
||||
}
|
||||
await waitForWeixinDelay(context.signal, 300)
|
||||
|
||||
const responsePromise = waitForWeixinPublishNetworkResponse(page)
|
||||
const responsePromise = waitForWeixinPublishNetworkResponse(
|
||||
page,
|
||||
WEIXIN_PUBLISH_RESPONSE_TIMEOUT_MS,
|
||||
context.signal,
|
||||
)
|
||||
const confirm = await clickWeixinVisibleAction(page, ['发表', '确认发表', '发布', '确认发布'], {
|
||||
scope: 'dialog',
|
||||
timeoutMs: 8_000,
|
||||
@@ -1831,10 +1855,13 @@ async function publishDraftFromDraftManagePage(
|
||||
throw new Error('weixin_gzh_playwright_page_missing')
|
||||
}
|
||||
|
||||
await page.goto(buildDraftManageUrl(meta.token), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
})
|
||||
await waitForWeixinEditorReady(page)
|
||||
await raceWithAbort(
|
||||
page.goto(buildDraftManageUrl(meta.token), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
}),
|
||||
{ signal: context.signal, timeoutMs: WEIXIN_EDITOR_READY_TIMEOUT_MS },
|
||||
)
|
||||
await waitForWeixinEditorReady(page, context.signal)
|
||||
|
||||
const initialMessage = await readWeixinPageMessage(page)
|
||||
if (/loginpage|\/cgi-bin\/login/i.test(page.url()) || /登录超时|重新登录/.test(initialMessage)) {
|
||||
@@ -1844,6 +1871,7 @@ async function publishDraftFromDraftManagePage(
|
||||
const directResponsePromise = waitForWeixinPublishNetworkResponse(
|
||||
page,
|
||||
WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000,
|
||||
context.signal,
|
||||
)
|
||||
const popupPromise = page.waitForEvent('popup', { timeout: 8_000 }).catch(() => null)
|
||||
const openDialog = await clickWeixinDraftCardPublish(page, articleId, title, context.signal)
|
||||
@@ -1858,7 +1886,7 @@ async function publishDraftFromDraftManagePage(
|
||||
if (popup && !popup.isClosed()) {
|
||||
popup.setDefaultTimeout(WEIXIN_EDITOR_READY_TIMEOUT_MS)
|
||||
popup.setDefaultNavigationTimeout(WEIXIN_EDITOR_READY_TIMEOUT_MS)
|
||||
await waitForWeixinEditorReady(popup)
|
||||
await waitForWeixinEditorReady(popup, context.signal)
|
||||
const popupMessage = await readWeixinPageMessage(popup)
|
||||
if (/loginpage|\/cgi-bin\/login/i.test(popup.url()) || /登录超时|重新登录/.test(popupMessage)) {
|
||||
throw new Error('weixin_gzh_not_logged_in')
|
||||
@@ -1866,7 +1894,11 @@ async function publishDraftFromDraftManagePage(
|
||||
const popupResponse = await completeWeixinPublishConfirmation(
|
||||
context,
|
||||
popup,
|
||||
waitForWeixinPublishNetworkResponse(popup, WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000),
|
||||
waitForWeixinPublishNetworkResponse(
|
||||
popup,
|
||||
WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000,
|
||||
context.signal,
|
||||
),
|
||||
)
|
||||
await popup.close().catch(() => undefined)
|
||||
return popupResponse
|
||||
@@ -1885,10 +1917,13 @@ async function publishDraftFromEditorPage(
|
||||
throw new Error('weixin_gzh_playwright_page_missing')
|
||||
}
|
||||
|
||||
await page.goto(buildDraftEditUrl(articleId, meta.token), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
})
|
||||
await waitForWeixinEditorReady(page)
|
||||
await raceWithAbort(
|
||||
page.goto(buildDraftEditUrl(articleId, meta.token), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
}),
|
||||
{ signal: context.signal, timeoutMs: WEIXIN_EDITOR_READY_TIMEOUT_MS },
|
||||
)
|
||||
await waitForWeixinEditorReady(page, context.signal)
|
||||
|
||||
const initialMessage = await readWeixinPageMessage(page)
|
||||
if (/loginpage|\/cgi-bin\/login/i.test(page.url()) || /登录超时|重新登录/.test(initialMessage)) {
|
||||
@@ -1898,6 +1933,7 @@ async function publishDraftFromEditorPage(
|
||||
const directResponsePromise = waitForWeixinPublishNetworkResponse(
|
||||
page,
|
||||
WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000,
|
||||
context.signal,
|
||||
)
|
||||
const openDialog = await clickWeixinVisibleAction(page, ['发表', '发布'], {
|
||||
timeoutMs: 12_000,
|
||||
@@ -2091,6 +2127,7 @@ export const weixinGzhAdapter: PublishAdapter = {
|
||||
context.reportProgress('weixin_gzh.upload_content_images')
|
||||
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
|
||||
uploadImage(context, meta, sourceUrl),
|
||||
{ signal: context.signal },
|
||||
)
|
||||
const content = wrapWeixinContent(processed.html)
|
||||
|
||||
@@ -2112,6 +2149,7 @@ export const weixinGzhAdapter: PublishAdapter = {
|
||||
}),
|
||||
body: form,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(
|
||||
(error): WeixinOperateResponse => ({
|
||||
ret: -1,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { marked } from 'marked'
|
||||
|
||||
import type { PublishAdapter } from './base'
|
||||
import {
|
||||
adapterFetch,
|
||||
extractImageSources,
|
||||
fetchImageBlob,
|
||||
normalizeRemoteUrl,
|
||||
@@ -56,7 +57,7 @@ async function zhihuFetch<T>(
|
||||
return await sessionFetchJson<T>(context.session, input, {
|
||||
...init,
|
||||
headers: buildHeaders(init?.headers),
|
||||
})
|
||||
}, { signal: context.signal })
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown: string): string {
|
||||
@@ -134,7 +135,7 @@ async function uploadBinaryImage(
|
||||
context: Parameters<NonNullable<PublishAdapter['publish']>>[0],
|
||||
sourceUrl: string,
|
||||
): Promise<string | null> {
|
||||
const blob = await fetchImageBlob(sourceUrl)
|
||||
const blob = await fetchImageBlob(sourceUrl, { signal: context.signal })
|
||||
if (!blob || !blob.type.startsWith('image/')) {
|
||||
return null
|
||||
}
|
||||
@@ -159,7 +160,7 @@ async function uploadBinaryImage(
|
||||
const stringToSign = `PUT\n\n${blob.type}\n${ossDate}\nx-oss-date:${ossDate}\nx-oss-security-token:${token.upload_token.access_token}\nx-oss-user-agent:${ossUserAgent}\n/zhihu-pics/v2-${imageHash}`
|
||||
const signature = hmacSha1Base64(token.upload_token.access_key || '', stringToSign)
|
||||
|
||||
const uploadResponse = await fetch(
|
||||
const uploadResponse = await adapterFetch(
|
||||
`https://zhihu-pics-upload.zhimg.com/${token.upload_file.object_key}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
@@ -173,6 +174,7 @@ async function uploadBinaryImage(
|
||||
},
|
||||
body: blob,
|
||||
},
|
||||
{ signal: context.signal, timeoutMs: 30_000 },
|
||||
)
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`zhihu_oss_upload_failed_${uploadResponse.status}`)
|
||||
|
||||
@@ -168,6 +168,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<ZolAccount
|
||||
credentials: 'include',
|
||||
headers: await zolHeaders(context),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
const user = response?.data
|
||||
@@ -217,6 +218,7 @@ async function createDraftId(context: PublishAdapterContext, form: FormData): Pr
|
||||
headers: await zolHeaders(context),
|
||||
body: form,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(
|
||||
(error): ZolDraftResponse => ({
|
||||
errcode: -1,
|
||||
@@ -254,6 +256,7 @@ async function uploadCoverVariant(
|
||||
headers: await zolHeaders(context),
|
||||
body: form,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(
|
||||
(error): ZolImageUploadResponse => ({
|
||||
errcode: -1,
|
||||
@@ -281,7 +284,7 @@ async function uploadCover(
|
||||
context: PublishAdapterContext,
|
||||
sourceUrl: string,
|
||||
): Promise<ZolCoverImage[] | null> {
|
||||
const source = await fetchImageAssetBlob(sourceUrl)
|
||||
const source = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
|
||||
if (!source) {
|
||||
throw new Error('zol_cover_fetch_failed')
|
||||
}
|
||||
@@ -327,6 +330,7 @@ async function uploadContentImageByNetworkPic(
|
||||
headers: await zolHeaders(context),
|
||||
body: form,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
const uploaded = zolUploadedImageUrl(response)
|
||||
@@ -345,7 +349,7 @@ async function uploadContentImageByFile(
|
||||
context: PublishAdapterContext,
|
||||
sourceUrl: string,
|
||||
): Promise<string | null> {
|
||||
const image = await fetchImageAssetBlob(sourceUrl)
|
||||
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
|
||||
if (!image) {
|
||||
throw new Error(
|
||||
`zol_content_image_upload_failed:${sourceUrl.slice(0, 160)}:source_fetch_failed`,
|
||||
@@ -367,6 +371,7 @@ async function uploadContentImageByFile(
|
||||
headers: await zolHeaders(context),
|
||||
body: form,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(
|
||||
(error): ZolImageUploadResponse => ({
|
||||
errcode: -1,
|
||||
@@ -421,10 +426,15 @@ async function applyRecommendSubjects(
|
||||
url.searchParams.set('keyword', '')
|
||||
url.searchParams.set('page', '1')
|
||||
|
||||
const response = await sessionFetchJson<ZolSubjectListResponse>(context.session, url.toString(), {
|
||||
credentials: 'include',
|
||||
headers: await zolHeaders(context),
|
||||
}).catch(() => null)
|
||||
const response = await sessionFetchJson<ZolSubjectListResponse>(
|
||||
context.session,
|
||||
url.toString(),
|
||||
{
|
||||
credentials: 'include',
|
||||
headers: await zolHeaders(context),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(() => null)
|
||||
|
||||
const subjects = (response?.data?.list ?? [])
|
||||
.filter((item) => item.subjectId != null && item.subjectName?.trim())
|
||||
@@ -598,6 +608,7 @@ export const zolAdapter: PublishAdapter = {
|
||||
headers: await zolHeaders(context),
|
||||
body: form,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
).catch(
|
||||
(error): ZolPublishResponse => ({
|
||||
errcode: -1,
|
||||
|
||||
@@ -66,6 +66,7 @@ import { initScheduler } from './scheduler'
|
||||
import { initSessionRegistry } from './session-registry'
|
||||
import { initSingleInstance } from './single-instance'
|
||||
import {
|
||||
cancelDesktopTask,
|
||||
checkDesktopClientRelease,
|
||||
initTransport,
|
||||
listDesktopPublishTasks,
|
||||
@@ -1013,6 +1014,9 @@ function registerBridgeHandlers(): void {
|
||||
safeHandle('desktop:retry-publish-task', async (_event, taskId: string) => {
|
||||
return retryDesktopPublishTask(taskId)
|
||||
})
|
||||
safeHandle('desktop:cancel-publish-task', async (_event, taskId: string) => {
|
||||
return cancelDesktopTask(taskId, { reason: 'user_cancelled_publish' })
|
||||
})
|
||||
safeHandle('desktop:open-external-url', async (_event, url: string) => {
|
||||
if (!isSafeExternalUrl(url)) {
|
||||
throw new Error('unsupported_external_url')
|
||||
|
||||
@@ -118,6 +118,7 @@ import {
|
||||
leaseDesktopTask,
|
||||
leaseMonitoringTasks,
|
||||
listDesktopAccounts,
|
||||
markDesktopTaskPublishSubmitStarted,
|
||||
noteTransportHeartbeat,
|
||||
noteTransportPull,
|
||||
offlineDesktopClient,
|
||||
@@ -148,7 +149,11 @@ type MonitorExecutionPhase =
|
||||
|
||||
const heartbeatIntervalMs = 15_000
|
||||
const pullIntervalMs = 60_000
|
||||
const pumpWatchdogIntervalMs = 15_000
|
||||
const leaseExtendIntervalMs = 60_000
|
||||
const defaultPublishTaskTimeoutMs = 5 * 60_000
|
||||
const publishTaskStaleProgressMs = 2 * 60_000
|
||||
const publishLongRunningNotificationMs = 15 * 60_000
|
||||
const maxActivityItems = 60
|
||||
const monitorBusinessTimeZone = 'Asia/Shanghai'
|
||||
const authRequiredMonitorPlatforms = new Set(aiPlatformCatalog.map((platform) => platform.id))
|
||||
@@ -171,6 +176,7 @@ interface RuntimeTaskRecord {
|
||||
status: RuntimeTaskStatus
|
||||
routing: RuntimeTaskRouting
|
||||
leaseExpiresAt: number | null
|
||||
startedAt: number | null
|
||||
updatedAt: number
|
||||
summary: string
|
||||
payload: Record<string, JsonValue>
|
||||
@@ -226,6 +232,16 @@ interface ActiveRuntimeExecution {
|
||||
interruptGeneration: number
|
||||
interruptReason: string | null
|
||||
lastSafePointAt: number | null
|
||||
startedAt: number
|
||||
lastProgressAt: number
|
||||
lastProgressSummary: string | null
|
||||
deadlineAt: number | null
|
||||
deadlineHandle: ReturnType<typeof setTimeout> | null
|
||||
longRunningNotifiedAt: number | null
|
||||
// Set the moment a publish adapter enters its irreversible submit/save_draft stage. Once set,
|
||||
// an interrupted publish is completed as 'unknown' (manual reconcile) instead of 'failed', so a
|
||||
// possibly-already-published article is never silently re-posted by a retry.
|
||||
submitStartedAt: number | null
|
||||
}
|
||||
|
||||
interface RuntimeState {
|
||||
@@ -241,6 +257,7 @@ interface RuntimeState {
|
||||
publishFallbackBackoffUntil: number
|
||||
heartbeatTimer: ReturnType<typeof setInterval> | null
|
||||
pullTimer: ReturnType<typeof setInterval> | null
|
||||
pumpWatchdogTimer: ReturnType<typeof setInterval> | null
|
||||
accountHealthReportTimer: ReturnType<typeof setTimeout> | null
|
||||
accountHealthReportQueue: Map<string, DesktopAccountHealthReport>
|
||||
accountHealthReportBadAccounts: Set<string>
|
||||
@@ -271,6 +288,7 @@ const state: RuntimeState = {
|
||||
publishFallbackBackoffUntil: 0,
|
||||
heartbeatTimer: null,
|
||||
pullTimer: null,
|
||||
pumpWatchdogTimer: null,
|
||||
accountHealthReportTimer: null,
|
||||
accountHealthReportQueue: new Map<string, DesktopAccountHealthReport>(),
|
||||
accountHealthReportBadAccounts: new Set<string>(),
|
||||
@@ -659,7 +677,10 @@ export function getRuntimeControllerSnapshot(): RuntimeControllerSnapshot {
|
||||
),
|
||||
tasks: [...state.tasks.values()]
|
||||
.sort((left, right) => right.updatedAt - left.updatedAt)
|
||||
.map(({ leaseToken: _leaseToken, ...task }) => ({ ...task })),
|
||||
.map(({ leaseToken: _leaseToken, ...task }) => ({
|
||||
...task,
|
||||
startedAt: state.activeExecutions.get(task.id)?.startedAt ?? task.startedAt,
|
||||
})),
|
||||
activity: [...state.activity],
|
||||
lastHeartbeatAt: state.lastHeartbeatAt,
|
||||
lastHeartbeatStatus: state.lastHeartbeatStatus,
|
||||
@@ -690,6 +711,7 @@ function startRuntime(): void {
|
||||
connectDispatchWs()
|
||||
scheduleHeartbeatLoop()
|
||||
schedulePullLoop()
|
||||
schedulePumpWatchdogLoop()
|
||||
|
||||
void sendHeartbeat('startup')
|
||||
void syncAccounts('startup')
|
||||
@@ -714,6 +736,10 @@ function stopRuntime(): void {
|
||||
state.running = false
|
||||
state.leaseInFlightKinds.clear()
|
||||
for (const execution of state.activeExecutions.values()) {
|
||||
if (execution.deadlineHandle) {
|
||||
clearTimeout(execution.deadlineHandle)
|
||||
execution.deadlineHandle = null
|
||||
}
|
||||
execution.abortController.abort()
|
||||
}
|
||||
state.activeExecutions.clear()
|
||||
@@ -726,6 +752,10 @@ function stopRuntime(): void {
|
||||
clearInterval(state.pullTimer)
|
||||
state.pullTimer = null
|
||||
}
|
||||
if (state.pumpWatchdogTimer) {
|
||||
clearInterval(state.pumpWatchdogTimer)
|
||||
state.pumpWatchdogTimer = null
|
||||
}
|
||||
if (state.accountHealthReportTimer) {
|
||||
clearTimeout(state.accountHealthReportTimer)
|
||||
state.accountHealthReportTimer = null
|
||||
@@ -793,6 +823,17 @@ function schedulePullLoop(): void {
|
||||
}, pullIntervalMs)
|
||||
}
|
||||
|
||||
function schedulePumpWatchdogLoop(): void {
|
||||
if (state.pumpWatchdogTimer) {
|
||||
clearInterval(state.pumpWatchdogTimer)
|
||||
}
|
||||
|
||||
state.pumpWatchdogTimer = setInterval(() => {
|
||||
notifyLongRunningPublishTasks()
|
||||
pumpExecutionLoop()
|
||||
}, pumpWatchdogIntervalMs)
|
||||
}
|
||||
|
||||
function connectDispatchWs(): void {
|
||||
if (!canRunLive(state.session)) {
|
||||
return
|
||||
@@ -909,6 +950,7 @@ function handleTaskEvent(event: DesktopTaskEventMessage): void {
|
||||
status: event.status,
|
||||
routing: existing?.routing ?? 'rabbitmq-primary',
|
||||
leaseExpiresAt: event.status === 'in_progress' ? (existing?.leaseExpiresAt ?? null) : null,
|
||||
startedAt: event.status === 'in_progress' ? (existing?.startedAt ?? eventUpdatedAt) : null,
|
||||
updatedAt: eventUpdatedAt,
|
||||
summary: summaryFromEventType(event.type, event.status),
|
||||
payload: existing?.payload ?? {},
|
||||
@@ -1577,6 +1619,114 @@ function enqueuePublishTask(taskId: string, platform: string, routing: RuntimeTa
|
||||
syncSchedulerSurface()
|
||||
}
|
||||
|
||||
function createActiveExecution(
|
||||
taskRecord: RuntimeTaskRecord,
|
||||
abortController: AbortController,
|
||||
): ActiveRuntimeExecution {
|
||||
const now = Date.now()
|
||||
return {
|
||||
taskId: taskRecord.id,
|
||||
kind: taskRecord.kind,
|
||||
platform: taskRecord.platform,
|
||||
accountId: taskRecord.accountId,
|
||||
abortController,
|
||||
executionPhase: taskRecord.kind === 'monitor' ? 'PREPARING' : null,
|
||||
interruptRequested:
|
||||
taskRecord.kind === 'monitor' && Boolean(taskRecord.payload.interrupt_requested),
|
||||
interruptGeneration:
|
||||
taskRecord.kind === 'monitor'
|
||||
? toInterruptGeneration(taskRecord.payload.interrupt_generation)
|
||||
: 0,
|
||||
interruptReason: taskRecord.kind === 'monitor' ? interruptReasonFromTask(taskRecord) : null,
|
||||
lastSafePointAt: taskRecord.kind === 'monitor' ? now : null,
|
||||
startedAt: now,
|
||||
lastProgressAt: now,
|
||||
lastProgressSummary: taskRecord.summary,
|
||||
deadlineAt: null,
|
||||
deadlineHandle: null,
|
||||
longRunningNotifiedAt: null,
|
||||
submitStartedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
function armPublishTaskDeadline(
|
||||
taskRecord: RuntimeTaskRecord,
|
||||
active: ActiveRuntimeExecution,
|
||||
): void {
|
||||
if (taskRecord.kind !== 'publish') {
|
||||
return
|
||||
}
|
||||
|
||||
const taskDeadlineMs = resolvePublishTaskTimeoutMs(taskRecord.platform)
|
||||
active.deadlineAt = active.startedAt + taskDeadlineMs
|
||||
active.deadlineHandle = setTimeout(() => {
|
||||
if (active.abortController.signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutError = new Error('publish_task_timeout')
|
||||
recordActivity(
|
||||
'warn',
|
||||
'任务超时',
|
||||
`${taskRecord.title} 执行超过 ${Math.round(taskDeadlineMs / 1000)}s,已中止并释放。`,
|
||||
)
|
||||
updateTaskProgress(taskRecord.id, `${taskRecord.title} 执行超时,正在释放任务。`)
|
||||
active.abortController.abort(timeoutError)
|
||||
}, taskDeadlineMs)
|
||||
}
|
||||
|
||||
function resolvePublishTaskTimeoutMs(platform: string): number {
|
||||
const envKey = `GEO_DESKTOP_PUBLISH_TIMEOUT_${platform.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_MS`
|
||||
return resolvePositiveEnvInteger(envKey) ?? resolvePositiveEnvInteger('GEO_DESKTOP_PUBLISH_TIMEOUT_MS') ?? defaultPublishTaskTimeoutMs
|
||||
}
|
||||
|
||||
function resolvePositiveEnvInteger(key: string): number | null {
|
||||
const raw = process.env[key]?.trim()
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
// Stages emitted by an adapter right before it performs an irreversible platform write (creating
|
||||
// the article/draft). After this point the article may exist on the platform regardless of whether
|
||||
// the local task records it, so an interrupted publish must not be auto-retried.
|
||||
function isIrreversiblePublishStage(stage: string): boolean {
|
||||
const normalized = stage.trim().toLowerCase()
|
||||
return (
|
||||
normalized.endsWith('.submit') ||
|
||||
normalized.endsWith('.save_draft') ||
|
||||
normalized === 'submit' ||
|
||||
normalized === 'save_draft'
|
||||
)
|
||||
}
|
||||
|
||||
// Persist a durable "submit started" marker (server-side, before/at the irreversible POST) and a
|
||||
// local flag the abort path reads. Fires at most once per execution and never blocks publishing.
|
||||
function markPublishSubmitStartedIfNeeded(taskId: string, stage: string): void {
|
||||
if (!isIrreversiblePublishStage(stage)) {
|
||||
return
|
||||
}
|
||||
const active = state.activeExecutions.get(taskId)
|
||||
if (!active || active.kind !== 'publish' || active.submitStartedAt !== null) {
|
||||
return
|
||||
}
|
||||
active.submitStartedAt = Date.now()
|
||||
state.activeExecutions.set(taskId, active)
|
||||
|
||||
const leaseToken = state.tasks.get(taskId)?.leaseToken
|
||||
if (!leaseToken) {
|
||||
return
|
||||
}
|
||||
void markDesktopTaskPublishSubmitStarted(taskId, leaseToken).catch((error) => {
|
||||
console.warn('[desktop-runtime] mark publish submit started failed', {
|
||||
taskId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function pumpExecutionLoop(): void {
|
||||
if (!state.running) {
|
||||
return
|
||||
@@ -1837,6 +1987,7 @@ function enqueueLeasedMonitoringTasks(
|
||||
status: 'queued',
|
||||
routing,
|
||||
leaseExpiresAt: parseTimestamp(task.lease_expires_at),
|
||||
startedAt: null,
|
||||
updatedAt: now,
|
||||
summary: `已领取监控租约,等待本地调度执行(${describeRouting(routing)})。`,
|
||||
payload: {
|
||||
@@ -1920,18 +2071,7 @@ async function executeLeasedMonitoringTask(
|
||||
recordActivity('info', '监控任务开始执行', `${taskRecord.title} 已进入本地执行队列。`)
|
||||
|
||||
const abortController = new AbortController()
|
||||
state.activeExecutions.set(taskRecord.id, {
|
||||
taskId: taskRecord.id,
|
||||
kind: 'monitor',
|
||||
platform: taskRecord.platform,
|
||||
accountId: taskRecord.accountId,
|
||||
abortController,
|
||||
executionPhase: 'PREPARING',
|
||||
interruptRequested: Boolean(taskRecord.payload.interrupt_requested),
|
||||
interruptGeneration: toInterruptGeneration(taskRecord.payload.interrupt_generation),
|
||||
interruptReason: interruptReasonFromTask(taskRecord),
|
||||
lastSafePointAt: Date.now(),
|
||||
})
|
||||
state.activeExecutions.set(taskRecord.id, createActiveExecution(taskRecord, abortController))
|
||||
noteMonitorTaskActivated(taskId)
|
||||
syncSchedulerSurface()
|
||||
queueMicrotask(() => pumpExecutionLoop())
|
||||
@@ -2382,6 +2522,7 @@ function noteMonitorExecutionSafePoint(taskId: string, phase: MonitorExecutionPh
|
||||
active.executionPhase = phase
|
||||
}
|
||||
active.lastSafePointAt = Date.now()
|
||||
active.lastProgressAt = Date.now()
|
||||
state.activeExecutions.set(taskId, active)
|
||||
|
||||
const taskRecord = state.tasks.get(taskId)
|
||||
@@ -2406,6 +2547,7 @@ function requestMonitorStaleBusinessDateDrop(taskId: string, staleBusinessDate:
|
||||
|
||||
active.interruptReason = 'stale_business_date'
|
||||
active.lastSafePointAt = Date.now()
|
||||
active.lastProgressAt = Date.now()
|
||||
state.activeExecutions.set(taskId, active)
|
||||
|
||||
const existing = state.tasks.get(taskId)
|
||||
@@ -2523,22 +2665,9 @@ async function executeLeasedTask(
|
||||
)
|
||||
|
||||
const abortController = new AbortController()
|
||||
state.activeExecutions.set(taskRecord.id, {
|
||||
taskId: taskRecord.id,
|
||||
kind: taskRecord.kind,
|
||||
platform: taskRecord.platform,
|
||||
accountId: taskRecord.accountId,
|
||||
abortController,
|
||||
executionPhase: taskRecord.kind === 'monitor' ? 'PREPARING' : null,
|
||||
interruptRequested:
|
||||
taskRecord.kind === 'monitor' && Boolean(taskRecord.payload.interrupt_requested),
|
||||
interruptGeneration:
|
||||
taskRecord.kind === 'monitor'
|
||||
? toInterruptGeneration(taskRecord.payload.interrupt_generation)
|
||||
: 0,
|
||||
interruptReason: taskRecord.kind === 'monitor' ? interruptReasonFromTask(taskRecord) : null,
|
||||
lastSafePointAt: taskRecord.kind === 'monitor' ? Date.now() : null,
|
||||
})
|
||||
const activeExecution = createActiveExecution(taskRecord, abortController)
|
||||
armPublishTaskDeadline(taskRecord, activeExecution)
|
||||
state.activeExecutions.set(taskRecord.id, activeExecution)
|
||||
|
||||
if (task.kind === 'monitor') {
|
||||
noteMonitorTaskLeased(task, routing)
|
||||
@@ -2598,7 +2727,23 @@ async function executeLeasedTask(
|
||||
const structuredError = toStructuredError(error)
|
||||
const accountIdentity = accountIdentityFromTask(taskRecord)
|
||||
|
||||
if (accountIdentity) {
|
||||
// If a publish task was interrupted AFTER it may have entered the irreversible submit phase,
|
||||
// the article might already be live on the platform. Completing as a clean 'failed' would drop
|
||||
// it from the server dedup set and let a retry double-post. Complete as 'unknown' instead so it
|
||||
// stays for manual reconcile. (A pre-submit interruption is a true failure and stays 'failed'.)
|
||||
const submitMaybeStarted =
|
||||
taskRecord.kind === 'publish' && activeExecution.submitStartedAt !== null
|
||||
const terminalStatus: 'failed' | 'unknown' = submitMaybeStarted ? 'unknown' : 'failed'
|
||||
const interruptedSummary = '发布可能已提交到平台,结果待人工确认,已避免自动重复发布。'
|
||||
// Tag the persisted error so the desktop UI can require explicit confirmation before a manual
|
||||
// retry of a possibly-already-published task (mirrors the server recovery payload marker).
|
||||
const terminalError = submitMaybeStarted
|
||||
? { ...structuredError, publish_submit_uncertain: true }
|
||||
: structuredError
|
||||
|
||||
// A submit-phase interruption is typically a network/timeout stall, not an auth problem — don't
|
||||
// wrongly degrade account health for it.
|
||||
if (accountIdentity && !submitMaybeStarted) {
|
||||
await reportAccountFailure(accountIdentity, {
|
||||
summary: failureMessage,
|
||||
error: structuredError,
|
||||
@@ -2615,22 +2760,24 @@ async function executeLeasedTask(
|
||||
try {
|
||||
const completed = await completeDesktopTask(taskRecord.id, {
|
||||
lease_token: leased.lease_token,
|
||||
status: 'failed',
|
||||
error: structuredError,
|
||||
status: terminalStatus,
|
||||
error: terminalError,
|
||||
})
|
||||
|
||||
upsertTaskFromInfo(completed, {
|
||||
routing,
|
||||
summary: failureMessage || '桌面任务执行失败。',
|
||||
summary: submitMaybeStarted ? interruptedSummary : failureMessage || '桌面任务执行失败。',
|
||||
attemptId: null,
|
||||
leaseToken: null,
|
||||
})
|
||||
} catch (completeError) {
|
||||
const existing = state.tasks.get(taskRecord.id)
|
||||
if (existing) {
|
||||
existing.status = 'failed'
|
||||
existing.error = structuredError
|
||||
existing.summary = `${failureMessage || '桌面任务执行失败。'}(result 回写失败:${errorMessage(completeError)})`
|
||||
existing.status = terminalStatus
|
||||
existing.error = terminalError
|
||||
existing.summary = submitMaybeStarted
|
||||
? `${interruptedSummary}(result 回写失败:${errorMessage(completeError)})`
|
||||
: `${failureMessage || '桌面任务执行失败。'}(result 回写失败:${errorMessage(completeError)})`
|
||||
existing.updatedAt = Date.now()
|
||||
state.tasks.set(taskRecord.id, existing)
|
||||
}
|
||||
@@ -2638,12 +2785,18 @@ async function executeLeasedTask(
|
||||
|
||||
noteLeaseReleased('failed', taskRecord.id)
|
||||
recordActivity(
|
||||
'danger',
|
||||
'任务执行失败',
|
||||
`${taskRecord.title} 执行失败:${failureMessage || '未知错误'}`,
|
||||
submitMaybeStarted ? 'warn' : 'danger',
|
||||
submitMaybeStarted ? '发布结果待确认' : '任务执行失败',
|
||||
submitMaybeStarted
|
||||
? `${taskRecord.title} 进入提交阶段后中断,可能已发布,已转人工确认以避免重复发文。`
|
||||
: `${taskRecord.title} 执行失败:${failureMessage || '未知错误'}`,
|
||||
)
|
||||
} finally {
|
||||
clearInterval(extendHandle)
|
||||
if (activeExecution.deadlineHandle) {
|
||||
clearTimeout(activeExecution.deadlineHandle)
|
||||
activeExecution.deadlineHandle = null
|
||||
}
|
||||
if (taskRecord.kind === 'publish') {
|
||||
notePublishTaskCompleted(taskRecord.id, taskRecord.platform)
|
||||
}
|
||||
@@ -2656,6 +2809,7 @@ async function executeLeasedTask(
|
||||
|
||||
async function extendActiveLease(taskId: string, leaseToken: string): Promise<void> {
|
||||
const existing = state.tasks.get(taskId)
|
||||
const active = state.activeExecutions.get(taskId)
|
||||
if (existing?.kind === 'monitor') {
|
||||
const staleBusinessDate = resolveStaleMonitoringBusinessDate(existing.payload)
|
||||
if (staleBusinessDate) {
|
||||
@@ -2663,6 +2817,28 @@ async function extendActiveLease(taskId: string, leaseToken: string): Promise<vo
|
||||
return
|
||||
}
|
||||
}
|
||||
if (existing?.kind === 'publish' && active) {
|
||||
const idleMs = Date.now() - active.lastProgressAt
|
||||
if (idleMs >= publishTaskStaleProgressMs) {
|
||||
const detail = active.lastProgressSummary ? `,最后进度:${active.lastProgressSummary}` : ''
|
||||
existing.summary = `发布任务超过 ${Math.round(idleMs / 1000)} 秒没有新进度,已中止并释放,准备由服务端重排${detail}。`
|
||||
existing.updatedAt = Date.now()
|
||||
state.tasks.set(taskId, existing)
|
||||
recordActivity(
|
||||
'warn',
|
||||
'发布任务疑似卡住',
|
||||
`${existing.title} ${existing.summary}`,
|
||||
)
|
||||
// Abort the local execution instead of merely letting the lease lapse: this
|
||||
// releases the slot immediately and, crucially, closes the window where the
|
||||
// server could re-queue the task while a stalled adapter is still mid-publish
|
||||
// (which would double-post a non-idempotent article).
|
||||
if (!active.abortController.signal.aborted) {
|
||||
active.abortController.abort(new Error('publish_stale_progress'))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const task = await extendDesktopTask(taskId, { lease_token: leaseToken })
|
||||
@@ -2673,7 +2849,10 @@ async function extendActiveLease(taskId: string, leaseToken: string): Promise<vo
|
||||
if (existing) {
|
||||
existing.leaseExpiresAt = parseTimestamp(task.lease_expires_at) ?? existing.leaseExpiresAt
|
||||
existing.updatedAt = Date.now()
|
||||
existing.summary = '租约已自动续期,任务仍在执行。'
|
||||
existing.summary =
|
||||
existing.kind === 'publish'
|
||||
? (active?.lastProgressSummary ?? '租约已自动续期,发布任务仍在执行。')
|
||||
: '租约已自动续期,任务仍在执行。'
|
||||
state.tasks.set(taskId, existing)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2738,6 +2917,7 @@ async function executeTaskAdapter(
|
||||
reportProgress(stage: string) {
|
||||
noteMonitorExecutionSafePoint(task.id, null)
|
||||
updateTaskProgress(task.id, `publish adapter progress: ${stage}`)
|
||||
markPublishSubmitStartedIfNeeded(task.id, stage)
|
||||
},
|
||||
},
|
||||
payload,
|
||||
@@ -3077,6 +3257,17 @@ function updateTaskProgress(taskId: string, summary: string): void {
|
||||
existing.summary = summary
|
||||
existing.updatedAt = Date.now()
|
||||
state.tasks.set(taskId, existing)
|
||||
|
||||
const active = state.activeExecutions.get(taskId)
|
||||
if (active) {
|
||||
active.lastProgressAt = Date.now()
|
||||
active.lastProgressSummary = summary
|
||||
state.activeExecutions.set(taskId, active)
|
||||
}
|
||||
|
||||
if (existing.kind === 'publish') {
|
||||
emitRuntimeInvalidated('publish-task-progress', { taskId })
|
||||
}
|
||||
}
|
||||
|
||||
function upsertTaskFromInfo(
|
||||
@@ -3102,6 +3293,7 @@ function upsertTaskFromInfo(
|
||||
status: task.status,
|
||||
routing: options.routing ?? existing?.routing ?? 'db-recovery',
|
||||
leaseExpiresAt: parseTimestamp(task.lease_expires_at) ?? null,
|
||||
startedAt: task.status === 'in_progress' ? (existing?.startedAt ?? Date.now()) : null,
|
||||
updatedAt: parseTimestamp(task.updated_at) ?? Date.now(),
|
||||
summary: options.summary ?? existing?.summary ?? summaryFromTaskStatus(task.status),
|
||||
payload,
|
||||
@@ -3149,6 +3341,42 @@ function syncSchedulerSurface(): void {
|
||||
setSchedulerCurrentTask(primaryActiveTaskId())
|
||||
}
|
||||
|
||||
function notifyLongRunningPublishTasks(): void {
|
||||
const now = Date.now()
|
||||
for (const active of state.activeExecutions.values()) {
|
||||
if (active.kind !== 'publish' || active.longRunningNotifiedAt) {
|
||||
continue
|
||||
}
|
||||
if (now - active.startedAt < publishLongRunningNotificationMs) {
|
||||
continue
|
||||
}
|
||||
active.longRunningNotifiedAt = now
|
||||
state.activeExecutions.set(active.taskId, active)
|
||||
|
||||
const task = state.tasks.get(active.taskId)
|
||||
if (task) {
|
||||
task.summary = `${task.summary || '发布任务仍在执行。'}(已执行 ${formatDurationText(now - active.startedAt)},如网络异常可取消后重试。)`
|
||||
task.updatedAt = now
|
||||
state.tasks.set(task.id, task)
|
||||
recordActivity('warn', '发布等待较久', `${task.title} 已执行 ${formatDurationText(now - active.startedAt)},可能存在网络异常。`)
|
||||
emitRuntimeInvalidated('publish-task-progress', { taskId: task.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDurationText(ms: number): string {
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000))
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const restSeconds = seconds % 60
|
||||
if (minutes <= 0) {
|
||||
return `${restSeconds} 秒`
|
||||
}
|
||||
if (restSeconds === 0) {
|
||||
return `${minutes} 分钟`
|
||||
}
|
||||
return `${minutes} 分 ${restSeconds} 秒`
|
||||
}
|
||||
|
||||
function localQueueDepth(): number {
|
||||
const publish = getPublishSchedulerSnapshot()
|
||||
const monitor = getMonitorSchedulerSnapshot()
|
||||
@@ -3229,7 +3457,10 @@ function activeAdmissionCount(): number {
|
||||
}
|
||||
|
||||
function hasTotalCapacityForNewExecution(): boolean {
|
||||
return activeAdmissionCount() < resolveAdaptiveTotalConcurrency()
|
||||
return (
|
||||
activeAdmissionCount() < resolveAdaptiveTotalConcurrency() &&
|
||||
shouldAdmitNewExecutionUnderRuntimePressure()
|
||||
)
|
||||
}
|
||||
|
||||
function canStartAnotherPublishExecution(): boolean {
|
||||
@@ -3355,9 +3586,13 @@ function resolveHardwareTotalConcurrency(): number {
|
||||
}
|
||||
|
||||
function resolveRuntimeHealthTotalConcurrency(hardwareCap: number): number {
|
||||
return hardwareCap
|
||||
}
|
||||
|
||||
function shouldAdmitNewExecutionUnderRuntimePressure(): boolean {
|
||||
const metrics = getProcessMetricsSnapshot().latestSample
|
||||
if (!metrics) {
|
||||
return Math.min(hardwareCap, minimumForegroundTotalConcurrency)
|
||||
return true
|
||||
}
|
||||
|
||||
const totalCPUUsage = metrics.processes.reduce((sum, item) => sum + item.percentCPUUsage, 0)
|
||||
@@ -3371,19 +3606,10 @@ function resolveRuntimeHealthTotalConcurrency(hardwareCap: number): number {
|
||||
mainPrivateBytesKB >= 1_000_000 ||
|
||||
processCount >= 40
|
||||
) {
|
||||
return minimumForegroundTotalConcurrency
|
||||
return activeAdmissionCount() < minimumForegroundTotalConcurrency
|
||||
}
|
||||
|
||||
if (
|
||||
totalCPUUsage >= 120 ||
|
||||
totalPrivateBytesKB >= 1_800_000 ||
|
||||
mainPrivateBytesKB >= 750_000 ||
|
||||
processCount >= 32
|
||||
) {
|
||||
return Math.min(hardwareCap, 3)
|
||||
}
|
||||
|
||||
return hardwareCap
|
||||
return true
|
||||
}
|
||||
|
||||
function resolveFeatureFlagTotalConcurrency(): number | null {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
type RuntimeInvalidationReason = 'account-health' | 'publish-task-lease'
|
||||
type RuntimeInvalidationReason = 'account-health' | 'publish-task-lease' | 'publish-task-progress'
|
||||
|
||||
type RuntimeInvalidationListener = (event: {
|
||||
reason: RuntimeInvalidationReason
|
||||
|
||||
@@ -690,6 +690,16 @@ export async function completeDesktopTask(
|
||||
)
|
||||
}
|
||||
|
||||
export async function markDesktopTaskPublishSubmitStarted(
|
||||
taskId: string,
|
||||
leaseToken: string,
|
||||
): Promise<void> {
|
||||
await desktopPost<{ ok: boolean }, { lease_token: string }>(
|
||||
`/api/desktop/tasks/${taskId}/publish-submit-marker`,
|
||||
{ lease_token: leaseToken },
|
||||
)
|
||||
}
|
||||
|
||||
export async function cancelDesktopTask(
|
||||
taskId: string,
|
||||
payload: CancelDesktopTaskRequest,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
DesktopClientUpdateResult,
|
||||
DesktopPublishTaskListResponse,
|
||||
DesktopRuntimeSessionSyncRequest,
|
||||
DesktopTaskInfo,
|
||||
ListDesktopPublishTasksParams,
|
||||
} from '@geo/shared-types'
|
||||
import type { IpcRendererEvent } from 'electron'
|
||||
@@ -167,6 +168,8 @@ const desktopBridge = {
|
||||
) as Promise<DesktopPublishTaskListResponse>,
|
||||
retryPublishTask: (taskId: string) =>
|
||||
ipcRenderer.invoke('desktop:retry-publish-task', taskId) as Promise<CreatePublishJobResponse>,
|
||||
cancelPublishTask: (taskId: string) =>
|
||||
ipcRenderer.invoke('desktop:cancel-publish-task', taskId) as Promise<DesktopTaskInfo>,
|
||||
openExternalUrl: (url: string) =>
|
||||
ipcRenderer.invoke('desktop:open-external-url', url) as Promise<null>,
|
||||
openSettingsWindow: () => ipcRenderer.invoke('desktop:open-settings-window') as Promise<null>,
|
||||
@@ -197,7 +200,7 @@ const desktopBridge = {
|
||||
ipcRenderer.invoke('desktop:set-window-mode', mode, request) as 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
|
||||
@@ -206,7 +209,7 @@ const desktopBridge = {
|
||||
const wrapped = (
|
||||
_event: IpcRendererEvent,
|
||||
payload: {
|
||||
reason: 'account-health' | 'publish-task-lease'
|
||||
reason: 'account-health' | 'publish-task-lease' | 'publish-task-progress'
|
||||
at: number
|
||||
accountId?: string
|
||||
taskId?: string
|
||||
|
||||
+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;
|
||||
|
||||
@@ -99,9 +99,13 @@ func main() {
|
||||
app.Logger,
|
||||
app.Config().Scheduler,
|
||||
).WithKolGenerationService(kolGenerationSvc).WithConfigProvider(app.ConfigStore)
|
||||
desktopTaskService := tenantapp.NewDesktopTaskService(app.DB, app.MonitoringDB, app.RabbitMQ, app.Logger).
|
||||
WithCache(app.Cache).
|
||||
WithRedis(app.Redis)
|
||||
monitoringDailyTaskWorker := tenantapp.NewMonitoringDailyTaskWorker(monitoringService, monitoringCallbackService, app.Logger)
|
||||
monitoringResultRecoveryWorker := internalscheduler.NewMonitoringResultRecoveryWorker(app.MonitoringDB, monitoringCallbackService, app.Logger)
|
||||
monitoringLeaseRecoveryWorker := internalscheduler.NewMonitoringLeaseRecoveryWorker(app.MonitoringDB, app.Logger)
|
||||
publishLeaseRecoveryWorker := internalscheduler.NewPublishLeaseRecoveryWorker(desktopTaskService, app.Logger)
|
||||
monitoringReceivedInspectionWorker := internalscheduler.NewMonitoringReceivedInspectionWorker(app.MonitoringDB, app.Logger)
|
||||
generationStateCheckWorker := generateworker.NewGenerationTaskStateCheckWorker(app.DB, app.Logger, app.Config().Generation).
|
||||
WithConfigProvider(app.ConfigStore).
|
||||
@@ -141,6 +145,12 @@ func main() {
|
||||
return monitoringLeaseRecoveryWorker.RunOnce(runCtx, jobCtx)
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "publish_lease_recovery",
|
||||
Run: func(runCtx context.Context, jobCtx internalscheduler.JobRunContext) (map[string]any, error) {
|
||||
return publishLeaseRecoveryWorker.RunOnce(runCtx, jobCtx)
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "monitoring_received_inspection",
|
||||
Run: func(runCtx context.Context, jobCtx internalscheduler.JobRunContext) (map[string]any, error) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
tenantapp "github.com/geo-platform/tenant-api/internal/tenant/app"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPublishLeaseRecoveryInterval = 3 * time.Minute
|
||||
defaultPublishLeaseRecoveryTimeout = 30 * time.Second
|
||||
defaultPublishLeaseRecoveryLimit = 1000
|
||||
)
|
||||
|
||||
type PublishLeaseRecoveryWorker struct {
|
||||
service *tenantapp.DesktopTaskService
|
||||
logger *zap.Logger
|
||||
interval time.Duration
|
||||
timeout time.Duration
|
||||
limit int
|
||||
}
|
||||
|
||||
func NewPublishLeaseRecoveryWorker(service *tenantapp.DesktopTaskService, logger *zap.Logger) *PublishLeaseRecoveryWorker {
|
||||
return &PublishLeaseRecoveryWorker{
|
||||
service: service,
|
||||
logger: logger,
|
||||
interval: defaultPublishLeaseRecoveryInterval,
|
||||
timeout: defaultPublishLeaseRecoveryTimeout,
|
||||
limit: defaultPublishLeaseRecoveryLimit,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *PublishLeaseRecoveryWorker) Start(ctx context.Context) {
|
||||
go w.Run(ctx)
|
||||
}
|
||||
|
||||
func (w *PublishLeaseRecoveryWorker) Run(ctx context.Context) {
|
||||
if w == nil || w.service == nil {
|
||||
return
|
||||
}
|
||||
w.run(ctx)
|
||||
}
|
||||
|
||||
func (w *PublishLeaseRecoveryWorker) run(ctx context.Context) {
|
||||
w.runOnce(context.Background())
|
||||
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.runOnce(context.Background())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *PublishLeaseRecoveryWorker) runOnce(parent context.Context) {
|
||||
ctx, cancel := context.WithTimeout(parent, w.timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := w.service.RecoverExpiredPublishTasks(ctx, w.limit)
|
||||
if err != nil {
|
||||
if w.logger != nil {
|
||||
w.logger.Warn("publish lease recovery failed", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (result.Requeued > 0 || result.Failed > 0 || result.Uncertain > 0) && w.logger != nil {
|
||||
w.logger.Info("publish lease recovery completed",
|
||||
zap.Int("requeued_task_count", result.Requeued),
|
||||
zap.Int("failed_task_count", result.Failed),
|
||||
zap.Int("uncertain_task_count", result.Uncertain),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *PublishLeaseRecoveryWorker) RunOnce(parent context.Context, run JobRunContext) (map[string]any, error) {
|
||||
if w == nil || w.service == nil {
|
||||
return map[string]any{"skipped": true, "reason": "worker_not_ready"}, nil
|
||||
}
|
||||
startedAt := time.Now()
|
||||
timeout := w.timeout
|
||||
if run.Job != nil && run.Job.TimeoutSeconds > 0 {
|
||||
timeout = time.Duration(run.Job.TimeoutSeconds) * time.Second
|
||||
}
|
||||
limit := w.limit
|
||||
if run.Job != nil && run.Job.BatchSize != nil && *run.Job.BatchSize > 0 {
|
||||
limit = *run.Job.BatchSize
|
||||
}
|
||||
if value := AsInt(run.Config, "batch_size", 0); value > 0 && (run.Job == nil || run.Job.BatchSize == nil) {
|
||||
limit = value
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := w.service.RecoverExpiredPublishTasks(ctx, limit)
|
||||
if err != nil {
|
||||
return map[string]any{"stage": "recover"}, err
|
||||
}
|
||||
return map[string]any{
|
||||
"requeued_task_count": result.Requeued,
|
||||
"failed_task_count": result.Failed,
|
||||
"uncertain_task_count": result.Uncertain,
|
||||
"duration_ms": time.Since(startedAt).Milliseconds(),
|
||||
"batch_size": limit,
|
||||
}, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +25,8 @@ import (
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
const desktopPublishMaxAttempts = 3
|
||||
|
||||
type DesktopTaskService struct {
|
||||
pool *pgxpool.Pool
|
||||
monitoringPool *pgxpool.Pool
|
||||
@@ -107,6 +110,10 @@ type ExtendDesktopTaskRequest struct {
|
||||
LeaseToken string `json:"lease_token" binding:"required"`
|
||||
}
|
||||
|
||||
type MarkPublishSubmitStartedRequest struct {
|
||||
LeaseToken string `json:"lease_token" binding:"required"`
|
||||
}
|
||||
|
||||
type CompleteDesktopTaskRequest struct {
|
||||
LeaseToken string `json:"lease_token" binding:"required"`
|
||||
Status string `json:"status" binding:"required,oneof=succeeded failed unknown"`
|
||||
@@ -200,8 +207,10 @@ func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.Deskt
|
||||
task, err = s.leaseQueuedDesktopTaskByID(ctx, client, *taskID, leaseParams)
|
||||
case req.Kind != nil && strings.TrimSpace(*req.Kind) == "monitor":
|
||||
task, err = s.leaseNextQueuedMonitorTask(ctx, client, leaseParams)
|
||||
case req.Kind != nil && strings.TrimSpace(*req.Kind) == "publish":
|
||||
task, err = s.leaseNextQueuedPublishTask(ctx, client, leaseParams)
|
||||
default:
|
||||
task, err = s.repo.LeaseNextQueued(ctx, client.ID, req.Kind, leaseParams)
|
||||
task, err = s.leaseNextQueuedPublishTask(ctx, client, leaseParams)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -438,6 +447,54 @@ func (s *DesktopTaskService) leaseNextQueuedMonitorTask(
|
||||
return scanRepositoryDesktopTask(row)
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) leaseNextQueuedPublishTask(
|
||||
ctx context.Context,
|
||||
client *repository.DesktopClient,
|
||||
params repository.DesktopTaskLeaseParams,
|
||||
) (*repository.DesktopTask, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
WITH candidate AS (
|
||||
SELECT dt.desktop_id
|
||||
FROM desktop_tasks AS dt
|
||||
WHERE dt.target_client_id = $1
|
||||
AND dt.kind = 'publish'
|
||||
AND dt.status = 'queued'
|
||||
AND dt.attempts < $4
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM desktop_publish_jobs AS j
|
||||
WHERE j.desktop_id = dt.job_id
|
||||
AND j.status <> 'queued'
|
||||
)
|
||||
)
|
||||
ORDER BY dt.lane_weight DESC,
|
||||
dt.priority DESC,
|
||||
COALESCE(dt.enqueued_at, dt.created_at) ASC,
|
||||
dt.created_at ASC,
|
||||
dt.desktop_id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE OF dt SKIP LOCKED
|
||||
)
|
||||
UPDATE desktop_tasks AS t
|
||||
SET active_attempt_id = $2,
|
||||
lease_token_hash = $3,
|
||||
lease_expires_at = now() + interval '3 minutes',
|
||||
status = 'in_progress',
|
||||
attempts = t.attempts + 1,
|
||||
started_at = COALESCE(t.started_at, now()),
|
||||
updated_at = now()
|
||||
FROM candidate
|
||||
WHERE t.desktop_id = candidate.desktop_id
|
||||
RETURNING `+desktopTaskRepositoryReturningColumns,
|
||||
client.ID,
|
||||
params.AttemptID,
|
||||
params.LeaseTokenHash,
|
||||
desktopPublishMaxAttempts,
|
||||
)
|
||||
return scanRepositoryDesktopTask(row)
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) leaseQueuedDesktopTaskByID(
|
||||
ctx context.Context,
|
||||
client *repository.DesktopClient,
|
||||
@@ -457,6 +514,7 @@ func (s *DesktopTaskService) leaseQueuedDesktopTaskByID(
|
||||
WHERE dt.desktop_id = $1
|
||||
AND dt.workspace_id = $2
|
||||
AND dt.status = 'queued'
|
||||
AND (dt.kind <> 'publish' OR dt.attempts < $9)
|
||||
AND (
|
||||
(
|
||||
dt.kind = 'monitor'
|
||||
@@ -478,9 +536,10 @@ func (s *DesktopTaskService) leaseQueuedDesktopTaskByID(
|
||||
SET target_client_id = CASE WHEN t.kind = 'monitor' THEN $4 ELSE t.target_client_id END,
|
||||
active_attempt_id = $7,
|
||||
lease_token_hash = $8,
|
||||
lease_expires_at = now() + interval '10 minutes',
|
||||
lease_expires_at = now() + CASE WHEN t.kind = 'publish' THEN interval '3 minutes' ELSE interval '10 minutes' END,
|
||||
status = 'in_progress',
|
||||
attempts = t.attempts + 1,
|
||||
started_at = COALESCE(t.started_at, now()),
|
||||
updated_at = now()
|
||||
FROM candidate
|
||||
WHERE t.desktop_id = candidate.desktop_id
|
||||
@@ -493,6 +552,7 @@ func (s *DesktopTaskService) leaseQueuedDesktopTaskByID(
|
||||
accountIDs,
|
||||
params.AttemptID,
|
||||
params.LeaseTokenHash,
|
||||
desktopPublishMaxAttempts,
|
||||
)
|
||||
return scanRepositoryDesktopTask(row)
|
||||
}
|
||||
@@ -638,6 +698,12 @@ func (s *DesktopTaskService) Extend(ctx context.Context, client *repository.Desk
|
||||
if err := s.dropStaleMonitorTasksForClient(ctx, client); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.redis != nil {
|
||||
presence := loadDesktopClientPresence(ctx, s.redis, []uuid.UUID{client.ID})
|
||||
if presence != nil && !presence[client.ID] {
|
||||
return nil, response.ErrConflict(40986, "desktop_client_offline", "desktop client presence has expired")
|
||||
}
|
||||
}
|
||||
|
||||
task, err := s.repo.ExtendLease(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(req.LeaseToken)))
|
||||
if err != nil {
|
||||
@@ -653,6 +719,34 @@ func (s *DesktopTaskService) Extend(ctx context.Context, client *repository.Desk
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
// MarkPublishSubmitStarted records a durable intent marker right before the client performs the
|
||||
// irreversible platform submit POST. Lease-recovery uses it to avoid silently re-posting a
|
||||
// non-idempotent article: once submit may have started, recovery routes the task to 'unknown'
|
||||
// (manual reconcile) instead of auto-requeueing it. Best-effort — a stale/lost lease is a no-op
|
||||
// and must never block the in-flight publish.
|
||||
func (s *DesktopTaskService) MarkPublishSubmitStarted(ctx context.Context, client *repository.DesktopClient, taskID uuid.UUID, leaseToken string) error {
|
||||
if s == nil || s.pool == nil || client == nil {
|
||||
return nil
|
||||
}
|
||||
leaseToken = strings.TrimSpace(leaseToken)
|
||||
if leaseToken == "" {
|
||||
return response.ErrBadRequest(40001, "invalid_params", "lease_token is required")
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
UPDATE desktop_tasks
|
||||
SET publish_submit_started_at = COALESCE(publish_submit_started_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE desktop_id = $1
|
||||
AND target_client_id = $2
|
||||
AND kind = 'publish'
|
||||
AND status = 'in_progress'
|
||||
AND lease_token_hash = $3
|
||||
`, taskID, client.ID, HashDesktopClientToken(leaseToken)); err != nil {
|
||||
return response.ErrInternal(50200, "desktop_task_submit_marker_failed", "failed to mark publish submit started")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) Complete(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req CompleteDesktopTaskRequest) (*DesktopTaskView, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
@@ -1320,6 +1414,33 @@ type recoveredDesktopTaskLease struct {
|
||||
Kind string
|
||||
Status string
|
||||
ActiveAttempt pgtype.UUID
|
||||
Attempts int
|
||||
SubmitStarted bool
|
||||
ErrorJSON []byte
|
||||
}
|
||||
|
||||
type PublishLeaseRecoveryResult struct {
|
||||
Requeued int `json:"requeued"`
|
||||
Failed int `json:"failed"`
|
||||
Uncertain int `json:"uncertain"`
|
||||
}
|
||||
|
||||
// resolvePublishRecoveryOutcome decides what to do with a publish task whose lease was lost.
|
||||
//
|
||||
// If the client had already entered the irreversible submit phase (publish_submit_started_at
|
||||
// is set), the article may already exist on the platform. Auto-requeueing would silently
|
||||
// re-post a non-idempotent article, so we route the task to 'unknown' (kept in the dedup
|
||||
// active set, surfaced for manual reconcile) instead. Tasks that never reached submit are
|
||||
// safe to auto-requeue while attempts remain, and give up to 'failed' once exhausted.
|
||||
func resolvePublishRecoveryOutcome(submitStarted bool, attempts int) (status string, payloadKind string) {
|
||||
switch {
|
||||
case submitStarted:
|
||||
return "unknown", "publish_uncertain"
|
||||
case attempts >= desktopPublishMaxAttempts:
|
||||
return "failed", "publish_final"
|
||||
default:
|
||||
return "queued", "publish"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) recoverClientTasksOnStartup(ctx context.Context, client *repository.DesktopClient) error {
|
||||
@@ -1347,6 +1468,14 @@ func (s *DesktopTaskService) recoverClientTasks(
|
||||
if err != nil {
|
||||
return response.ErrInternal(50195, "desktop_task_recovery_payload_failed", "failed to encode publish desktop task recovery payload")
|
||||
}
|
||||
publishFinalErrorJSON, _, _, err := desktopTaskRecoveryPayload(mode, "publish_final")
|
||||
if err != nil {
|
||||
return response.ErrInternal(50195, "desktop_task_recovery_payload_failed", "failed to encode publish desktop task recovery payload")
|
||||
}
|
||||
publishUncertainErrorJSON, _, _, err := desktopTaskRecoveryPayload(mode, "publish_uncertain")
|
||||
if err != nil {
|
||||
return response.ErrInternal(50195, "desktop_task_recovery_payload_failed", "failed to encode publish desktop task recovery payload")
|
||||
}
|
||||
monitorErrorJSON, monitorReason, _, err := desktopTaskRecoveryPayload(mode, "monitor")
|
||||
if err != nil {
|
||||
return response.ErrInternal(50195, "desktop_task_recovery_payload_failed", "failed to encode monitor desktop task recovery payload")
|
||||
@@ -1378,6 +1507,8 @@ func (s *DesktopTaskService) recoverClientTasks(
|
||||
&item.Kind,
|
||||
&item.Status,
|
||||
&item.ActiveAttempt,
|
||||
&item.Attempts,
|
||||
&item.SubmitStarted,
|
||||
); scanErr != nil {
|
||||
s.logWarn("desktop task recovery scan failed", scanErr, zap.String("client_id", client.ID.String()), zap.String("mode", string(mode)))
|
||||
return response.ErrInternal(50198, "desktop_task_recovery_scan_failed", "failed to parse recovered desktop tasks")
|
||||
@@ -1389,6 +1520,7 @@ func (s *DesktopTaskService) recoverClientTasks(
|
||||
return response.ErrInternal(50198, "desktop_task_recovery_scan_failed", "failed to iterate recovered desktop tasks")
|
||||
}
|
||||
|
||||
publishOutcomes := make([]*desktopPublishSyncOutcome, 0)
|
||||
for index := range recovered {
|
||||
item := &recovered[index]
|
||||
if item.Kind == "monitor" {
|
||||
@@ -1412,22 +1544,49 @@ func (s *DesktopTaskService) recoverClientTasks(
|
||||
continue
|
||||
}
|
||||
|
||||
finalStatus, payloadKind := resolvePublishRecoveryOutcome(item.SubmitStarted, item.Attempts)
|
||||
nextErrorJSON := publishErrorJSON
|
||||
switch payloadKind {
|
||||
case "publish_final":
|
||||
nextErrorJSON = publishFinalErrorJSON
|
||||
case "publish_uncertain":
|
||||
nextErrorJSON = publishUncertainErrorJSON
|
||||
}
|
||||
if _, execErr := tx.Exec(ctx, `
|
||||
UPDATE desktop_tasks
|
||||
SET status = 'unknown',
|
||||
error = $2,
|
||||
SET status = $2,
|
||||
error = $3,
|
||||
result = CASE WHEN $2 = 'queued' THEN NULL ELSE result END,
|
||||
active_attempt_id = NULL,
|
||||
lease_token_hash = NULL,
|
||||
lease_expires_at = NULL,
|
||||
interrupted_at = COALESCE(interrupted_at, NOW()),
|
||||
interrupt_reason = COALESCE(interrupt_reason, $3),
|
||||
interrupt_reason = COALESCE(interrupt_reason, $4),
|
||||
enqueued_at = CASE WHEN $2 = 'queued' THEN NOW() ELSE enqueued_at END,
|
||||
updated_at = NOW()
|
||||
WHERE desktop_id = $1
|
||||
`, item.TaskID, publishErrorJSON, publishReason); execErr != nil {
|
||||
`, item.TaskID, finalStatus, nextErrorJSON, publishReason); execErr != nil {
|
||||
s.logWarn("desktop publish task recovery update failed", execErr, zap.String("task_id", item.TaskID.String()), zap.String("mode", string(mode)))
|
||||
return response.ErrInternal(50197, "desktop_task_recovery_query_failed", "failed to select recoverable desktop tasks")
|
||||
}
|
||||
item.Status = "unknown"
|
||||
item.Status = finalStatus
|
||||
item.ErrorJSON = nextErrorJSON
|
||||
// Only terminal 'failed' tasks finalize the publish_record; 'unknown' (may already be
|
||||
// published) is left in the dedup active set for manual reconcile, 'queued' is requeued.
|
||||
if finalStatus == "failed" {
|
||||
task, getErr := repo.GetByDesktopID(ctx, item.TaskID, item.WorkspaceID)
|
||||
if getErr != nil {
|
||||
s.logWarn("desktop publish task recovery reload failed", getErr, zap.String("task_id", item.TaskID.String()), zap.String("mode", string(mode)))
|
||||
return response.ErrInternal(50197, "desktop_task_recovery_query_failed", "failed to select recoverable desktop tasks")
|
||||
}
|
||||
publishOutcome, syncErr := syncDesktopPublishTaskState(ctx, tx, task)
|
||||
if syncErr != nil {
|
||||
return syncErr
|
||||
}
|
||||
if publishOutcome != nil {
|
||||
publishOutcomes = append(publishOutcomes, publishOutcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range recovered {
|
||||
@@ -1442,7 +1601,10 @@ func (s *DesktopTaskService) recoverClientTasks(
|
||||
}
|
||||
|
||||
finalStatus := literalStringPtr(item.Status)
|
||||
errorJSON := publishErrorJSON
|
||||
errorJSON := item.ErrorJSON
|
||||
if len(errorJSON) == 0 {
|
||||
errorJSON = publishErrorJSON
|
||||
}
|
||||
if item.Kind == "monitor" {
|
||||
finalStatus = literalStringPtr("aborted")
|
||||
errorJSON = monitorErrorJSON
|
||||
@@ -1462,6 +1624,8 @@ func (s *DesktopTaskService) recoverClientTasks(
|
||||
return response.ErrInternal(50199, "desktop_task_recovery_commit_failed", "failed to commit desktop task recovery")
|
||||
}
|
||||
|
||||
s.afterRecoveredPublishOutcomes(ctx, publishOutcomes)
|
||||
|
||||
for _, item := range recovered {
|
||||
task, getErr := s.repo.GetByDesktopID(ctx, item.TaskID, item.WorkspaceID)
|
||||
if getErr != nil {
|
||||
@@ -1470,7 +1634,7 @@ func (s *DesktopTaskService) recoverClientTasks(
|
||||
}
|
||||
|
||||
eventType := "task_completed"
|
||||
if item.Kind == "monitor" {
|
||||
if item.Status == "queued" {
|
||||
eventType = "task_available"
|
||||
}
|
||||
s.publishTaskEvent(ctx, task, eventType)
|
||||
@@ -1488,15 +1652,199 @@ func (s *DesktopTaskService) recoverClientTasks(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) RecoverExpiredPublishTasks(ctx context.Context, limit int) (PublishLeaseRecoveryResult, error) {
|
||||
var result PublishLeaseRecoveryResult
|
||||
if s == nil || s.pool == nil {
|
||||
return result, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
publishErrorJSON, publishReason, _, err := desktopTaskRecoveryPayload(desktopTaskRecoveryModeLeaseExpiry, "publish")
|
||||
if err != nil {
|
||||
return result, response.ErrInternal(50195, "desktop_task_recovery_payload_failed", "failed to encode publish desktop task recovery payload")
|
||||
}
|
||||
publishFinalErrorJSON, _, _, err := desktopTaskRecoveryPayload(desktopTaskRecoveryModeLeaseExpiry, "publish_final")
|
||||
if err != nil {
|
||||
return result, response.ErrInternal(50195, "desktop_task_recovery_payload_failed", "failed to encode publish desktop task recovery payload")
|
||||
}
|
||||
publishUncertainErrorJSON, _, _, err := desktopTaskRecoveryPayload(desktopTaskRecoveryModeLeaseExpiry, "publish_uncertain")
|
||||
if err != nil {
|
||||
return result, response.ErrInternal(50195, "desktop_task_recovery_payload_failed", "failed to encode publish desktop task recovery payload")
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return result, response.ErrInternal(50196, "desktop_task_recovery_begin_failed", "failed to start desktop task recovery")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT
|
||||
t.desktop_id,
|
||||
t.workspace_id,
|
||||
t.kind,
|
||||
t.status,
|
||||
t.active_attempt_id,
|
||||
t.attempts,
|
||||
(t.publish_submit_started_at IS NOT NULL) AS submit_started
|
||||
FROM desktop_tasks AS t
|
||||
WHERE t.kind = 'publish'
|
||||
AND t.status = 'in_progress'
|
||||
AND t.lease_expires_at IS NOT NULL
|
||||
AND t.lease_expires_at < NOW()
|
||||
ORDER BY t.lease_expires_at ASC, t.updated_at ASC, t.desktop_id ASC
|
||||
LIMIT $1
|
||||
FOR UPDATE OF t SKIP LOCKED
|
||||
`, limit)
|
||||
if err != nil {
|
||||
s.logWarn("expired desktop publish recovery query failed", err)
|
||||
return result, response.ErrInternal(50197, "desktop_task_recovery_query_failed", "failed to select recoverable desktop tasks")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
recovered := make([]recoveredDesktopTaskLease, 0)
|
||||
for rows.Next() {
|
||||
var item recoveredDesktopTaskLease
|
||||
if scanErr := rows.Scan(
|
||||
&item.TaskID,
|
||||
&item.WorkspaceID,
|
||||
&item.Kind,
|
||||
&item.Status,
|
||||
&item.ActiveAttempt,
|
||||
&item.Attempts,
|
||||
&item.SubmitStarted,
|
||||
); scanErr != nil {
|
||||
s.logWarn("expired desktop publish recovery scan failed", scanErr)
|
||||
return result, response.ErrInternal(50198, "desktop_task_recovery_scan_failed", "failed to parse recovered desktop tasks")
|
||||
}
|
||||
recovered = append(recovered, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
s.logWarn("expired desktop publish recovery rows failed", err)
|
||||
return result, response.ErrInternal(50198, "desktop_task_recovery_scan_failed", "failed to iterate recovered desktop tasks")
|
||||
}
|
||||
|
||||
repo := repository.NewDesktopTaskRepository(tx)
|
||||
publishOutcomes := make([]*desktopPublishSyncOutcome, 0)
|
||||
for index := range recovered {
|
||||
item := &recovered[index]
|
||||
finalStatus, payloadKind := resolvePublishRecoveryOutcome(item.SubmitStarted, item.Attempts)
|
||||
nextErrorJSON := publishErrorJSON
|
||||
switch payloadKind {
|
||||
case "publish_final":
|
||||
nextErrorJSON = publishFinalErrorJSON
|
||||
case "publish_uncertain":
|
||||
nextErrorJSON = publishUncertainErrorJSON
|
||||
}
|
||||
if _, execErr := tx.Exec(ctx, `
|
||||
UPDATE desktop_tasks
|
||||
SET status = $2,
|
||||
error = $3,
|
||||
result = CASE WHEN $2 = 'queued' THEN NULL ELSE result END,
|
||||
active_attempt_id = NULL,
|
||||
lease_token_hash = NULL,
|
||||
lease_expires_at = NULL,
|
||||
interrupted_at = COALESCE(interrupted_at, NOW()),
|
||||
interrupt_reason = COALESCE(interrupt_reason, $4),
|
||||
enqueued_at = CASE WHEN $2 = 'queued' THEN NOW() ELSE enqueued_at END,
|
||||
updated_at = NOW()
|
||||
WHERE desktop_id = $1
|
||||
AND status = 'in_progress'
|
||||
`, item.TaskID, finalStatus, nextErrorJSON, publishReason); execErr != nil {
|
||||
s.logWarn("expired desktop publish recovery update failed", execErr, zap.String("task_id", item.TaskID.String()))
|
||||
return result, response.ErrInternal(50197, "desktop_task_recovery_query_failed", "failed to select recoverable desktop tasks")
|
||||
}
|
||||
|
||||
item.Status = finalStatus
|
||||
item.ErrorJSON = nextErrorJSON
|
||||
switch finalStatus {
|
||||
case "failed":
|
||||
result.Failed++
|
||||
task, getErr := repo.GetByDesktopID(ctx, item.TaskID, item.WorkspaceID)
|
||||
if getErr != nil {
|
||||
s.logWarn("expired desktop publish recovery reload failed", getErr, zap.String("task_id", item.TaskID.String()))
|
||||
return result, response.ErrInternal(50197, "desktop_task_recovery_query_failed", "failed to select recoverable desktop tasks")
|
||||
}
|
||||
publishOutcome, syncErr := syncDesktopPublishTaskState(ctx, tx, task)
|
||||
if syncErr != nil {
|
||||
return result, syncErr
|
||||
}
|
||||
if publishOutcome != nil {
|
||||
publishOutcomes = append(publishOutcomes, publishOutcome)
|
||||
}
|
||||
case "unknown":
|
||||
// May already be published — keep as 'unknown' (still in the dedup active set) for
|
||||
// manual reconcile. Do NOT sync to a terminal publish_record state and do NOT requeue.
|
||||
result.Uncertain++
|
||||
default:
|
||||
result.Requeued++
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range recovered {
|
||||
if !item.ActiveAttempt.Valid {
|
||||
continue
|
||||
}
|
||||
|
||||
attemptID, convErr := uuid.FromBytes(item.ActiveAttempt.Bytes[:])
|
||||
if convErr != nil {
|
||||
s.logWarn("expired desktop publish recovery attempt id decode failed", convErr, zap.String("task_id", item.TaskID.String()))
|
||||
continue
|
||||
}
|
||||
|
||||
if finishErr := repo.FinishAttempt(ctx, repository.FinishDesktopTaskAttemptParams{
|
||||
TaskID: item.TaskID,
|
||||
AttemptID: attemptID,
|
||||
FinalStatus: literalStringPtr(item.Status),
|
||||
Error: item.ErrorJSON,
|
||||
}); finishErr != nil {
|
||||
s.logWarn("expired desktop publish recovery attempt finish failed", finishErr, zap.String("task_id", item.TaskID.String()))
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return result, response.ErrInternal(50199, "desktop_task_recovery_commit_failed", "failed to commit desktop task recovery")
|
||||
}
|
||||
|
||||
s.afterRecoveredPublishOutcomes(ctx, publishOutcomes)
|
||||
|
||||
for _, item := range recovered {
|
||||
task, getErr := s.repo.GetByDesktopID(ctx, item.TaskID, item.WorkspaceID)
|
||||
if getErr != nil {
|
||||
s.logWarn("expired desktop publish recovery reload after commit failed", getErr, zap.String("task_id", item.TaskID.String()))
|
||||
continue
|
||||
}
|
||||
eventType := "task_completed"
|
||||
if item.Status == "queued" {
|
||||
eventType = "task_available"
|
||||
}
|
||||
s.publishTaskEvent(ctx, task, eventType)
|
||||
}
|
||||
|
||||
if len(recovered) > 0 && s.logger != nil {
|
||||
s.logger.Info("expired desktop publish tasks recovered",
|
||||
zap.Int("requeued", result.Requeued),
|
||||
zap.Int("failed", result.Failed),
|
||||
zap.Int("uncertain", result.Uncertain),
|
||||
)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func desktopTaskRecoverySelectQuery(mode desktopTaskRecoveryMode) string {
|
||||
query := `
|
||||
SELECT
|
||||
desktop_id,
|
||||
workspace_id,
|
||||
kind,
|
||||
status,
|
||||
active_attempt_id
|
||||
FROM desktop_tasks
|
||||
workspace_id,
|
||||
kind,
|
||||
status,
|
||||
active_attempt_id,
|
||||
attempts,
|
||||
publish_submit_started_at IS NOT NULL AS submit_started
|
||||
FROM desktop_tasks
|
||||
WHERE target_client_id = $1
|
||||
AND workspace_id = $2
|
||||
AND status = 'in_progress'
|
||||
@@ -1517,36 +1865,62 @@ func desktopTaskRecoveryPayload(mode desktopTaskRecoveryMode, kind string) ([]by
|
||||
reason := strings.TrimSpace(string(mode))
|
||||
message := "desktop task was recovered after the client lost the active lease"
|
||||
source := "desktop_task_recovery"
|
||||
isPublishFinal := kind == "publish_final"
|
||||
isPublishUncertain := kind == "publish_uncertain"
|
||||
|
||||
if isPublishFinal {
|
||||
message = fmt.Sprintf("desktop publish task exceeded %d attempts during recovery; task has been marked failed", desktopPublishMaxAttempts)
|
||||
} else if isPublishUncertain {
|
||||
message = "desktop publish task may have already been submitted to the platform before the lease was lost; kept as unknown for manual reconcile to avoid duplicate publishing"
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case desktopTaskRecoveryModeStartup:
|
||||
source = "desktop_client_startup"
|
||||
if kind == "monitor" {
|
||||
if isPublishFinal {
|
||||
message = fmt.Sprintf("desktop client restarted while the publish task was in progress; task exceeded %d attempts and has been marked failed", desktopPublishMaxAttempts)
|
||||
} else if isPublishUncertain {
|
||||
message = "desktop client restarted while the publish task was mid-submit; kept as unknown for manual reconcile to avoid duplicate publishing"
|
||||
} else if kind == "monitor" {
|
||||
message = "desktop client restarted while the monitor task was in progress; task has been re-queued"
|
||||
} else {
|
||||
message = "desktop client restarted while the publish task was in progress; task has been moved to unknown for manual reconcile"
|
||||
message = "desktop client restarted while the publish task was in progress; task has been re-queued for retry"
|
||||
}
|
||||
case desktopTaskRecoveryModeDisconnect:
|
||||
source = "desktop_client_offline"
|
||||
if kind == "monitor" {
|
||||
if isPublishFinal {
|
||||
message = fmt.Sprintf("desktop client went offline while the publish task was in progress; task exceeded %d attempts and has been marked failed", desktopPublishMaxAttempts)
|
||||
} else if isPublishUncertain {
|
||||
message = "desktop client went offline while the publish task was mid-submit; kept as unknown for manual reconcile to avoid duplicate publishing"
|
||||
} else if kind == "monitor" {
|
||||
message = "desktop client went offline while the monitor task was in progress; task has been re-queued"
|
||||
} else {
|
||||
message = "desktop client went offline while the publish task was in progress; task has been moved to unknown for manual reconcile"
|
||||
message = "desktop client went offline while the publish task was in progress; task has been re-queued for retry"
|
||||
}
|
||||
case desktopTaskRecoveryModeLeaseExpiry:
|
||||
source = "desktop_task_lease_expiry"
|
||||
if kind == "monitor" {
|
||||
if isPublishFinal {
|
||||
message = fmt.Sprintf("desktop task lease expired before publish completion; task exceeded %d attempts and has been marked failed", desktopPublishMaxAttempts)
|
||||
} else if isPublishUncertain {
|
||||
message = "desktop task lease expired while the publish task was mid-submit; kept as unknown for manual reconcile to avoid duplicate publishing"
|
||||
} else if kind == "monitor" {
|
||||
message = "desktop task lease expired before monitor completion; task has been re-queued"
|
||||
} else {
|
||||
message = "desktop task lease expired before publish completion; task has been moved to unknown for manual reconcile"
|
||||
message = "desktop task lease expired before publish completion; task has been re-queued for retry"
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := marshalOptionalJSON(map[string]any{
|
||||
fields := map[string]any{
|
||||
"reason": reason,
|
||||
"message": message,
|
||||
"source": source,
|
||||
})
|
||||
}
|
||||
if isPublishUncertain {
|
||||
// Surfaced to the desktop UI so a manual retry of a maybe-already-published task asks for
|
||||
// explicit confirmation instead of silently re-posting.
|
||||
fields["publish_submit_uncertain"] = true
|
||||
}
|
||||
payload, err := marshalOptionalJSON(fields)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
@@ -1781,6 +2155,20 @@ func (s *DesktopTaskService) publishTaskEvent(ctx context.Context, task *reposit
|
||||
publishDesktopTaskLifecycleEvent(ctx, s.messaging, s.logger, task, eventType)
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) afterRecoveredPublishOutcomes(ctx context.Context, outcomes []*desktopPublishSyncOutcome) {
|
||||
for _, outcome := range outcomes {
|
||||
if outcome == nil {
|
||||
continue
|
||||
}
|
||||
invalidateArticleCaches(ctx, s.cache, outcome.TenantID, &outcome.ArticleID)
|
||||
if outcome.ArticleAlias != nil {
|
||||
if syncErr := s.syncMonitoringArticleAlias(ctx, outcome.ArticleAlias); syncErr != nil {
|
||||
s.logWarn("monitoring article alias sync failed after publish recovery", syncErr, zap.Int64("article_id", outcome.ArticleID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reconcileDesktopTaskEventType(_ string, status string) string {
|
||||
if status == "retry" {
|
||||
return "task_available"
|
||||
|
||||
@@ -17,6 +17,8 @@ func TestDesktopTaskRecoverySelectQueryMatchesRecoveredLeaseScanOrder(t *testing
|
||||
"kind",
|
||||
"status",
|
||||
"active_attempt_id",
|
||||
"attempts",
|
||||
"publish_submit_started_at",
|
||||
}
|
||||
|
||||
if len(gotColumns) != len(wantColumns) {
|
||||
@@ -55,6 +57,45 @@ func TestReconcileRetryPublishesTaskAvailableForAllDesktopTaskKinds(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopTaskRecoveryPayloadPublishLeaseExpiryRequeuesInsteadOfUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload, reason, message, err := desktopTaskRecoveryPayload(desktopTaskRecoveryModeLeaseExpiry, "publish")
|
||||
if err != nil {
|
||||
t.Fatalf("desktopTaskRecoveryPayload returned error: %v", err)
|
||||
}
|
||||
if reason != "lease_expired" {
|
||||
t.Fatalf("reason = %q, want lease_expired", reason)
|
||||
}
|
||||
if !strings.Contains(message, "re-queued for retry") {
|
||||
t.Fatalf("message = %q, want re-queued for retry", message)
|
||||
}
|
||||
if strings.Contains(message, "unknown") {
|
||||
t.Fatalf("message must not move publish recovery to unknown: %q", message)
|
||||
}
|
||||
if !strings.Contains(string(payload), "desktop_task_lease_expiry") {
|
||||
t.Fatalf("payload missing source: %s", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopTaskRecoveryPayloadPublishFinalFailsAfterMaxAttempts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload, _, message, err := desktopTaskRecoveryPayload(desktopTaskRecoveryModeLeaseExpiry, "publish_final")
|
||||
if err != nil {
|
||||
t.Fatalf("desktopTaskRecoveryPayload returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(message, "marked failed") {
|
||||
t.Fatalf("message = %q, want marked failed", message)
|
||||
}
|
||||
if !strings.Contains(message, "3 attempts") {
|
||||
t.Fatalf("message = %q, want max attempts", message)
|
||||
}
|
||||
if !strings.Contains(string(payload), "marked failed") {
|
||||
t.Fatalf("payload missing final failure message: %s", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNonRetryPublishesTaskReconciled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -63,6 +104,38 @@ func TestReconcileNonRetryPublishesTaskReconciled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePublishRecoveryOutcome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
submitStarted bool
|
||||
attempts int
|
||||
wantStatus string
|
||||
wantKind string
|
||||
}{
|
||||
{"pre-submit first attempt requeues", false, 1, "queued", "publish"},
|
||||
{"pre-submit just below cap requeues", false, desktopPublishMaxAttempts - 1, "queued", "publish"},
|
||||
{"pre-submit at cap fails terminally", false, desktopPublishMaxAttempts, "failed", "publish_final"},
|
||||
{"pre-submit above cap fails terminally", false, desktopPublishMaxAttempts + 1, "failed", "publish_final"},
|
||||
// Submit may have happened: never auto-requeue or silently fail — route to unknown
|
||||
// (manual reconcile) regardless of remaining attempts, to avoid duplicate publishing.
|
||||
{"submit started first attempt is uncertain", true, 1, "unknown", "publish_uncertain"},
|
||||
{"submit started at cap is still uncertain", true, desktopPublishMaxAttempts, "unknown", "publish_uncertain"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gotStatus, gotKind := resolvePublishRecoveryOutcome(tc.submitStarted, tc.attempts)
|
||||
if gotStatus != tc.wantStatus || gotKind != tc.wantKind {
|
||||
t.Fatalf("resolvePublishRecoveryOutcome(%v, %d) = (%q, %q), want (%q, %q)",
|
||||
tc.submitStarted, tc.attempts, gotStatus, gotKind, tc.wantStatus, tc.wantKind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func recoverDesktopTaskSelectColumns(query string) []string {
|
||||
re := regexp.MustCompile(`(?is)select\s+(.*?)\s+from\s+desktop_tasks`)
|
||||
match := re.FindStringSubmatch(query)
|
||||
|
||||
@@ -420,7 +420,7 @@ func (q *Queries) CreateDesktopTaskAttempt(ctx context.Context, arg CreateDeskto
|
||||
|
||||
const extendDesktopTaskLease = `-- name: ExtendDesktopTaskLease :one
|
||||
UPDATE desktop_tasks
|
||||
SET lease_expires_at = now() + interval '10 minutes',
|
||||
SET lease_expires_at = now() + CASE WHEN kind = 'publish' THEN interval '3 minutes' ELSE interval '10 minutes' END,
|
||||
updated_at = now()
|
||||
WHERE desktop_id = $1
|
||||
AND lease_token_hash = $2
|
||||
@@ -564,6 +564,10 @@ WITH candidate AS (
|
||||
$4::text IS NULL
|
||||
OR dt.kind = $4::text
|
||||
)
|
||||
AND (
|
||||
dt.kind <> 'publish'
|
||||
OR dt.attempts < 3
|
||||
)
|
||||
AND (
|
||||
dt.kind <> 'publish'
|
||||
OR NOT EXISTS (
|
||||
@@ -584,9 +588,10 @@ WITH candidate AS (
|
||||
UPDATE desktop_tasks AS t
|
||||
SET active_attempt_id = $1,
|
||||
lease_token_hash = $2,
|
||||
lease_expires_at = now() + interval '10 minutes',
|
||||
lease_expires_at = now() + CASE WHEN t.kind = 'publish' THEN interval '3 minutes' ELSE interval '10 minutes' END,
|
||||
status = 'in_progress',
|
||||
attempts = t.attempts + 1,
|
||||
started_at = COALESCE(t.started_at, now()),
|
||||
updated_at = now()
|
||||
FROM candidate
|
||||
WHERE t.desktop_id = candidate.desktop_id
|
||||
@@ -650,13 +655,18 @@ const leaseQueuedDesktopTaskByDesktopID = `-- name: LeaseQueuedDesktopTaskByDesk
|
||||
UPDATE desktop_tasks
|
||||
SET active_attempt_id = $1,
|
||||
lease_token_hash = $2,
|
||||
lease_expires_at = now() + interval '10 minutes',
|
||||
lease_expires_at = now() + CASE WHEN kind = 'publish' THEN interval '3 minutes' ELSE interval '10 minutes' END,
|
||||
status = 'in_progress',
|
||||
attempts = attempts + 1,
|
||||
started_at = COALESCE(started_at, now()),
|
||||
updated_at = now()
|
||||
WHERE desktop_id = $3
|
||||
AND target_client_id = $4
|
||||
AND status = 'queued'
|
||||
AND (
|
||||
kind <> 'publish'
|
||||
OR attempts < 3
|
||||
)
|
||||
RETURNING id, desktop_id, job_id, tenant_id, workspace_id, target_account_id, target_client_id, platform_id, kind, payload, status, dedup_key, active_attempt_id, lease_token_hash, lease_expires_at, attempts, result, error, created_at, updated_at, priority, lane, lane_weight, source, scheduler_group_key, monitor_task_id, supersedes_task_id, control_flags, interrupt_generation, started_at, interrupted_at, interrupt_reason, enqueued_at
|
||||
`
|
||||
|
||||
@@ -754,7 +764,7 @@ SET status = CASE
|
||||
active_attempt_id = NULL,
|
||||
lease_token_hash = NULL,
|
||||
lease_expires_at = NULL,
|
||||
attempts = attempts + CASE WHEN $1::text = 'retry' THEN 1 ELSE 0 END,
|
||||
attempts = CASE WHEN $1::text = 'retry' THEN 0 ELSE attempts END,
|
||||
updated_at = now()
|
||||
WHERE desktop_id = $4
|
||||
AND workspace_id = $5
|
||||
|
||||
@@ -17,4 +17,29 @@ func TestLeaseNextQueuedDesktopTaskLocksOnlyDesktopTasks(t *testing.T) {
|
||||
if !strings.Contains(query, "NOT EXISTS") {
|
||||
t.Fatalf("lease query must filter non-queued publish jobs without joining the lock target; query:\n%s", query)
|
||||
}
|
||||
if !strings.Contains(query, "dt.attempts < 3") {
|
||||
t.Fatalf("lease query must cap publish retries; query:\n%s", query)
|
||||
}
|
||||
if !strings.Contains(query, "interval '3 minutes'") {
|
||||
t.Fatalf("lease query must use short publish lease ttl; query:\n%s", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendDesktopTaskLeaseUsesShortPublishTTL(t *testing.T) {
|
||||
query := extendDesktopTaskLease
|
||||
|
||||
if !strings.Contains(query, "CASE WHEN kind = 'publish' THEN interval '3 minutes' ELSE interval '10 minutes' END") {
|
||||
t.Fatalf("extend query must keep publish lease ttl short; query:\n%s", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileDesktopTaskRetryResetsAttempts(t *testing.T) {
|
||||
query := reconcileDesktopTask
|
||||
|
||||
if !strings.Contains(query, "attempts = CASE WHEN $1::text = 'retry' THEN 0 ELSE attempts END") {
|
||||
t.Fatalf("retry reconcile must reset attempts so manual retry can be leased; query:\n%s", query)
|
||||
}
|
||||
if strings.Contains(query, "attempts + CASE") {
|
||||
t.Fatalf("retry reconcile must not increment attempts; query:\n%s", query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,6 +634,93 @@ type MediaPlatform struct {
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type MediaSupplyOrder struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
BrandID int64 `json:"brand_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
ArticleID pgtype.Int8 `json:"article_id"`
|
||||
Supplier string `json:"supplier"`
|
||||
ModelID int32 `json:"model_id"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
ContentSnapshot string `json:"content_snapshot"`
|
||||
Remark pgtype.Text `json:"remark"`
|
||||
OrderBrand pgtype.Text `json:"order_brand"`
|
||||
ExternalOrderID pgtype.Text `json:"external_order_id"`
|
||||
ExternalOrderCode pgtype.Text `json:"external_order_code"`
|
||||
SupplierStatus pgtype.Text `json:"supplier_status"`
|
||||
CostTotalCents int64 `json:"cost_total_cents"`
|
||||
SellTotalCents int64 `json:"sell_total_cents"`
|
||||
WalletDebitCents int64 `json:"wallet_debit_cents"`
|
||||
WalletDebitedAt pgtype.Timestamptz `json:"wallet_debited_at"`
|
||||
WalletRefundedAt pgtype.Timestamptz `json:"wallet_refunded_at"`
|
||||
RequestPayloadJson []byte `json:"request_payload_json"`
|
||||
ResponsePayloadJson []byte `json:"response_payload_json"`
|
||||
ErrorMessage pgtype.Text `json:"error_message"`
|
||||
AttemptCount int32 `json:"attempt_count"`
|
||||
NextAttemptAt pgtype.Timestamptz `json:"next_attempt_at"`
|
||||
QueuedAt pgtype.Timestamptz `json:"queued_at"`
|
||||
SubmittedAt pgtype.Timestamptz `json:"submitted_at"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||
}
|
||||
|
||||
type MediaSupplyOrderItem struct {
|
||||
ID int64 `json:"id"`
|
||||
OrderID int64 `json:"order_id"`
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
SupplierResourceID string `json:"supplier_resource_id"`
|
||||
PriceType string `json:"price_type"`
|
||||
ResourceNameSnapshot string `json:"resource_name_snapshot"`
|
||||
LockedCostPriceCents int64 `json:"locked_cost_price_cents"`
|
||||
LockedSellPriceCents int64 `json:"locked_sell_price_cents"`
|
||||
Status string `json:"status"`
|
||||
ExternalArticleUrl pgtype.Text `json:"external_article_url"`
|
||||
RawJson []byte `json:"raw_json"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type MediaSupplySyncJob struct {
|
||||
ID int64 `json:"id"`
|
||||
Supplier string `json:"supplier"`
|
||||
ModelID pgtype.Int4 `json:"model_id"`
|
||||
Status string `json:"status"`
|
||||
RequestedBy pgtype.Int8 `json:"requested_by"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
FinishedAt pgtype.Timestamptz `json:"finished_at"`
|
||||
ErrorMessage pgtype.Text `json:"error_message"`
|
||||
AttemptCount int32 `json:"attempt_count"`
|
||||
StatsJson []byte `json:"stats_json"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type MediaSupplyUserWallet struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
BalanceCents int64 `json:"balance_cents"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type MediaSupplyWalletLedger struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
OrderID pgtype.Int8 `json:"order_id"`
|
||||
DeltaCents int64 `json:"delta_cents"`
|
||||
BalanceAfterCents int64 `json:"balance_after_cents"`
|
||||
Reason string `json:"reason"`
|
||||
Note pgtype.Text `json:"note"`
|
||||
CreatedBy pgtype.Int8 `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
ID int64 `json:"id"`
|
||||
PlanCode string `json:"plan_code"`
|
||||
@@ -797,6 +884,45 @@ type ScheduleTask struct {
|
||||
ConsecutiveFailures int32 `json:"consecutive_failures"`
|
||||
}
|
||||
|
||||
type SupplierMediaPriceOverride struct {
|
||||
ID int64 `json:"id"`
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
PriceType string `json:"price_type"`
|
||||
SellPriceCents int64 `json:"sell_price_cents"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedBy pgtype.Int8 `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SupplierMediaResource struct {
|
||||
ID int64 `json:"id"`
|
||||
Supplier string `json:"supplier"`
|
||||
SupplierResourceID string `json:"supplier_resource_id"`
|
||||
ModelID int32 `json:"model_id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
CostPriceCents int64 `json:"cost_price_cents"`
|
||||
CostPricesJson []byte `json:"cost_prices_json"`
|
||||
SalePriceLabel pgtype.Text `json:"sale_price_label"`
|
||||
ResourceUrl pgtype.Text `json:"resource_url"`
|
||||
BaiduWeight pgtype.Int4 `json:"baidu_weight"`
|
||||
ResourceRemark pgtype.Text `json:"resource_remark"`
|
||||
CustomerVisible bool `json:"customer_visible"`
|
||||
ChannelType pgtype.Text `json:"channel_type"`
|
||||
Region pgtype.Text `json:"region"`
|
||||
InclusionEffect pgtype.Text `json:"inclusion_effect"`
|
||||
LinkType pgtype.Text `json:"link_type"`
|
||||
PublishRate pgtype.Text `json:"publish_rate"`
|
||||
DeliverySpeed pgtype.Text `json:"delivery_speed"`
|
||||
SupplierUpdatedAt pgtype.Timestamptz `json:"supplier_updated_at"`
|
||||
LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"`
|
||||
RawJson []byte `json:"raw_json"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||
}
|
||||
|
||||
type TaskRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
|
||||
@@ -69,6 +69,10 @@ WITH candidate AS (
|
||||
sqlc.narg(kind)::text IS NULL
|
||||
OR dt.kind = sqlc.narg(kind)::text
|
||||
)
|
||||
AND (
|
||||
dt.kind <> 'publish'
|
||||
OR dt.attempts < 3
|
||||
)
|
||||
AND (
|
||||
dt.kind <> 'publish'
|
||||
OR NOT EXISTS (
|
||||
@@ -89,9 +93,10 @@ WITH candidate AS (
|
||||
UPDATE desktop_tasks AS t
|
||||
SET active_attempt_id = sqlc.arg(attempt_id),
|
||||
lease_token_hash = sqlc.arg(lease_token_hash),
|
||||
lease_expires_at = now() + interval '10 minutes',
|
||||
lease_expires_at = now() + CASE WHEN t.kind = 'publish' THEN interval '3 minutes' ELSE interval '10 minutes' END,
|
||||
status = 'in_progress',
|
||||
attempts = t.attempts + 1,
|
||||
started_at = COALESCE(t.started_at, now()),
|
||||
updated_at = now()
|
||||
FROM candidate
|
||||
WHERE t.desktop_id = candidate.desktop_id
|
||||
@@ -101,18 +106,23 @@ RETURNING t.*;
|
||||
UPDATE desktop_tasks
|
||||
SET active_attempt_id = sqlc.arg(attempt_id),
|
||||
lease_token_hash = sqlc.arg(lease_token_hash),
|
||||
lease_expires_at = now() + interval '10 minutes',
|
||||
lease_expires_at = now() + CASE WHEN kind = 'publish' THEN interval '3 minutes' ELSE interval '10 minutes' END,
|
||||
status = 'in_progress',
|
||||
attempts = attempts + 1,
|
||||
started_at = COALESCE(started_at, now()),
|
||||
updated_at = now()
|
||||
WHERE desktop_id = sqlc.arg(desktop_id)
|
||||
AND target_client_id = sqlc.arg(client_id)
|
||||
AND status = 'queued'
|
||||
AND (
|
||||
kind <> 'publish'
|
||||
OR attempts < 3
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ExtendDesktopTaskLease :one
|
||||
UPDATE desktop_tasks
|
||||
SET lease_expires_at = now() + interval '10 minutes',
|
||||
SET lease_expires_at = now() + CASE WHEN kind = 'publish' THEN interval '3 minutes' ELSE interval '10 minutes' END,
|
||||
updated_at = now()
|
||||
WHERE desktop_id = sqlc.arg(desktop_id)
|
||||
AND lease_token_hash = sqlc.arg(lease_token_hash)
|
||||
@@ -186,7 +196,7 @@ SET status = CASE
|
||||
active_attempt_id = NULL,
|
||||
lease_token_hash = NULL,
|
||||
lease_expires_at = NULL,
|
||||
attempts = attempts + CASE WHEN sqlc.arg(status)::text = 'retry' THEN 1 ELSE 0 END,
|
||||
attempts = CASE WHEN sqlc.arg(status)::text = 'retry' THEN 0 ELSE attempts END,
|
||||
updated_at = now()
|
||||
WHERE desktop_id = sqlc.arg(desktop_id)
|
||||
AND workspace_id = sqlc.arg(workspace_id)
|
||||
|
||||
@@ -111,6 +111,26 @@ func (h *DesktopTaskHandler) Extend(c *gin.Context) {
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *DesktopTaskHandler) MarkPublishSubmitStarted(c *gin.Context) {
|
||||
taskID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid"))
|
||||
return
|
||||
}
|
||||
|
||||
var req app.MarkPublishSubmitStartedRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.MarkPublishSubmitStarted(c.Request.Context(), MustDesktopClient(c.Request.Context()), taskID, req.LeaseToken); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *DesktopTaskHandler) Result(c *gin.Context) {
|
||||
desktopID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -66,6 +66,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
desktopAuth.POST("/tasks/lease", desktopTaskHandler.Lease)
|
||||
desktopAuth.POST("/tasks/:id/lease", desktopTaskHandler.Lease)
|
||||
desktopAuth.POST("/tasks/:id/extend", desktopTaskHandler.Extend)
|
||||
desktopAuth.POST("/tasks/:id/publish-submit-marker", desktopTaskHandler.MarkPublishSubmitStarted)
|
||||
desktopAuth.POST("/tasks/:id/cancel", desktopTaskHandler.Cancel)
|
||||
desktopAuth.POST("/tasks/:id/result", desktopTaskHandler.Result)
|
||||
desktopAuth.POST("/publish-tasks/:id/retry", desktopTaskHandler.RetryPublish)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_desktop_tasks_publish_lease_recovery;
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_desktop_tasks_publish_lease_recovery
|
||||
ON desktop_tasks (lease_expires_at ASC, updated_at ASC, desktop_id ASC)
|
||||
WHERE kind = 'publish'
|
||||
AND status = 'in_progress'
|
||||
AND lease_expires_at IS NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE desktop_tasks
|
||||
DROP COLUMN IF EXISTS publish_submit_started_at;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Durable "submit started" intent marker for desktop publish tasks.
|
||||
-- Set immediately before the irreversible platform submit POST so lease-recovery can tell
|
||||
-- "definitely not submitted yet" (safe to auto-requeue) apart from "may already be published"
|
||||
-- (route to 'unknown' / manual reconcile and never silently re-post a non-idempotent article).
|
||||
ALTER TABLE desktop_tasks
|
||||
ADD COLUMN IF NOT EXISTS publish_submit_started_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1,8 @@
|
||||
DELETE FROM ops.scheduler_job_triggers
|
||||
WHERE job_key = 'publish_lease_recovery';
|
||||
|
||||
DELETE FROM ops.scheduler_job_runs
|
||||
WHERE job_key = 'publish_lease_recovery';
|
||||
|
||||
DELETE FROM ops.scheduler_jobs
|
||||
WHERE job_key = 'publish_lease_recovery';
|
||||
@@ -0,0 +1,16 @@
|
||||
INSERT INTO ops.scheduler_jobs
|
||||
(job_key, display_name, category, description, enabled, schedule_type, interval_seconds, timezone, timeout_seconds, batch_size, config)
|
||||
VALUES
|
||||
('publish_lease_recovery', '发布租约回收', 'publish', '按过期租约限批回收卡住的桌面发布任务,超过最大重试后转失败。', true, 'interval', 180, 'Asia/Shanghai', 30, 1000, '{}'::jsonb)
|
||||
ON CONFLICT (job_key) DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
category = EXCLUDED.category,
|
||||
description = EXCLUDED.description,
|
||||
enabled = EXCLUDED.enabled,
|
||||
schedule_type = EXCLUDED.schedule_type,
|
||||
interval_seconds = EXCLUDED.interval_seconds,
|
||||
timezone = EXCLUDED.timezone,
|
||||
timeout_seconds = EXCLUDED.timeout_seconds,
|
||||
batch_size = EXCLUDED.batch_size,
|
||||
config = ops.scheduler_jobs.config || EXCLUDED.config,
|
||||
updated_at = NOW();
|
||||
Reference in New Issue
Block a user