gongxiangp2/lib/external-auto-listing.ts
chenpei 02ebaabd0e feat(外部接口): 新增自动上架成套通知能力
- 新增任务-详情全套入库后的自动上架通知与重试补发能力

- 扩展 product-assets 支持按 materialId 拉取正式资源

- 补充后台通知目标管理、投递记录和对接文档
2026-03-25 20:39:56 +08:00

939 lines
26 KiB
TypeScript

import { randomUUID } from 'crypto'
import { prisma } from '@/lib/prisma'
import { getPublicAppOrigin } from '@/lib/public-app-url'
export const AUTO_LISTING_EVENT_TYPE = 'task-detail.full-set.ready'
export const AUTO_LISTING_AUTH_TYPE = {
NONE: 'none',
BEARER: 'bearer',
HEADER: 'header',
} as const
export const AUTO_LISTING_DELIVERY_STATUS = {
PENDING: 'pending',
PROCESSING: 'processing',
RETRYING: 'retrying',
SUCCEEDED: 'succeeded',
EXHAUSTED: 'exhausted',
} as const
export interface ExternalNotificationTarget {
id: string
name: string
callbackUrl: string
isActive: boolean
authType: string
authConfig: Record<string, string>
eventTypes: string[]
createdAt: string
updatedAt: string
}
export interface ExternalNotificationDelivery {
id: string
targetId: string
targetName: string
eventType: string
materialId: string
materialType: string
taskNo: string | null
status: string
attemptCount: number
maxAttempts: number
nextRetryAt: string | null
lastStatusCode: number | null
lastResponseBody: string | null
lastError: string | null
payload: Record<string, unknown> | null
createdAt: string
updatedAt: string
completedAt: string | null
}
export interface ExternalNotificationTargetInput {
name: string
callbackUrl: string
isActive?: boolean
authType?: string
authConfig?: Record<string, string>
eventTypes?: string[]
}
const TARGET_TABLE = 'external_notification_targets'
const DELIVERY_TABLE = 'external_notification_deliveries'
const DELIVERY_MAX_ATTEMPTS = 5
const DELIVERY_RESPONSE_BODY_LIMIT = 2000
const RECOVERY_INTERVAL_MS = 60_000
const STALE_PROCESSING_MS = 10 * 60 * 1000
const RETRY_DELAYS_MS = [0, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
let isTableInitialized = false
let isWorkerStarted = false
let recoveryTimer: NodeJS.Timeout | null = null
const deliveryTimers = new Map<string, NodeJS.Timeout>()
function asJson(value: unknown, fallback: string) {
try {
return JSON.stringify(value ?? JSON.parse(fallback))
} catch {
return fallback
}
}
function toIsoString(value: unknown): string | null {
if (!value) {
return null
}
if (value instanceof Date) {
return value.toISOString()
}
const date = new Date(String(value))
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
function normalizeAuthType(value: string | null | undefined) {
if (value === AUTO_LISTING_AUTH_TYPE.BEARER || value === AUTO_LISTING_AUTH_TYPE.HEADER) {
return value
}
return AUTO_LISTING_AUTH_TYPE.NONE
}
function normalizeAuthConfig(input: unknown): Record<string, string> {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return {}
}
return Object.entries(input as Record<string, unknown>).reduce<Record<string, string>>((acc, [key, value]) => {
const normalizedKey = String(key || '').trim()
const normalizedValue = typeof value === 'string' ? value.trim() : ''
if (normalizedKey && normalizedValue) {
acc[normalizedKey] = normalizedValue
}
return acc
}, {})
}
function normalizeEventTypes(eventTypes?: string[] | null): string[] {
const normalized = Array.isArray(eventTypes)
? eventTypes.map((item) => String(item || '').trim()).filter(Boolean)
: []
return normalized.length > 0 ? Array.from(new Set(normalized)) : [AUTO_LISTING_EVENT_TYPE]
}
function normalizeTargetRow(row: any): ExternalNotificationTarget {
return {
id: String(row.id),
name: String(row.name || ''),
callbackUrl: String(row.callbackUrl || ''),
isActive: Boolean(row.isActive),
authType: normalizeAuthType(row.authType),
authConfig: normalizeAuthConfig(row.authConfig),
eventTypes: normalizeEventTypes(Array.isArray(row.eventTypes) ? row.eventTypes : []),
createdAt: toIsoString(row.createdAt) || new Date().toISOString(),
updatedAt: toIsoString(row.updatedAt) || new Date().toISOString(),
}
}
function normalizeDeliveryRow(row: any): ExternalNotificationDelivery {
return {
id: String(row.id),
targetId: String(row.targetId || ''),
targetName: String(row.targetName || ''),
eventType: String(row.eventType || ''),
materialId: String(row.materialId || ''),
materialType: String(row.materialType || ''),
taskNo: row.taskNo ? String(row.taskNo) : null,
status: String(row.status || AUTO_LISTING_DELIVERY_STATUS.PENDING),
attemptCount: Number(row.attemptCount || 0),
maxAttempts: Number(row.maxAttempts || DELIVERY_MAX_ATTEMPTS),
nextRetryAt: toIsoString(row.nextRetryAt),
lastStatusCode:
typeof row.lastStatusCode === 'number'
? row.lastStatusCode
: row.lastStatusCode != null
? Number(row.lastStatusCode)
: null,
lastResponseBody: row.lastResponseBody ? String(row.lastResponseBody) : null,
lastError: row.lastError ? String(row.lastError) : null,
payload:
row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload)
? (row.payload as Record<string, unknown>)
: null,
createdAt: toIsoString(row.createdAt) || new Date().toISOString(),
updatedAt: toIsoString(row.updatedAt) || new Date().toISOString(),
completedAt: toIsoString(row.completedAt),
}
}
function normalizeCallbackUrl(callbackUrl: string) {
const normalized = String(callbackUrl || '').trim()
if (!normalized) {
throw new Error('回调地址不能为空')
}
let parsedUrl: URL
try {
parsedUrl = new URL(normalized)
} catch {
throw new Error('回调地址格式不正确')
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('回调地址仅支持 HTTP 或 HTTPS')
}
return parsedUrl.toString()
}
function normalizeTargetInput(
input: ExternalNotificationTargetInput,
fallback?: ExternalNotificationTarget,
) {
const name = String(input.name ?? fallback?.name ?? '').trim()
if (!name) {
throw new Error('目标系统名称不能为空')
}
const callbackUrl = normalizeCallbackUrl(input.callbackUrl ?? fallback?.callbackUrl ?? '')
const authType = normalizeAuthType(input.authType ?? fallback?.authType)
const authConfig = normalizeAuthConfig(input.authConfig ?? fallback?.authConfig ?? {})
const eventTypes = normalizeEventTypes(input.eventTypes ?? fallback?.eventTypes)
const isActive = typeof input.isActive === 'boolean' ? input.isActive : Boolean(fallback?.isActive ?? true)
if (authType === AUTO_LISTING_AUTH_TYPE.BEARER && !String(authConfig.token || '').trim()) {
throw new Error('Bearer 鉴权必须提供 token')
}
if (authType === AUTO_LISTING_AUTH_TYPE.HEADER) {
const headerName = String(authConfig.headerName || '').trim()
const headerValue = String(authConfig.headerValue || '').trim()
if (!headerName || !headerValue) {
throw new Error('Header 鉴权必须提供请求头名称和值')
}
}
return {
name,
callbackUrl,
isActive,
authType,
authConfig,
eventTypes,
}
}
async function getExternalNotificationTargetById(targetId: string) {
const rows = await queryRaw<any>(
`
SELECT
id,
name,
callback_url AS "callbackUrl",
is_active AS "isActive",
auth_type AS "authType",
auth_config AS "authConfig",
event_types AS "eventTypes",
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM "${TARGET_TABLE}"
WHERE id = $1
LIMIT 1
`,
targetId,
)
return rows[0] ? normalizeTargetRow(rows[0]) : null
}
export async function createExternalNotificationTarget(input: ExternalNotificationTargetInput) {
await ensureExternalNotificationTables()
ensureWorkerStarted()
const normalized = normalizeTargetInput(input)
const targetId = randomUUID()
await executeRaw(
`
INSERT INTO "${TARGET_TABLE}" (
id,
name,
callback_url,
is_active,
auth_type,
auth_config,
event_types,
created_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, NOW(), NOW())
`,
targetId,
normalized.name,
normalized.callbackUrl,
normalized.isActive,
normalized.authType,
asJson(normalized.authConfig, '{}'),
asJson(normalized.eventTypes, '[]'),
)
const created = await getExternalNotificationTargetById(targetId)
if (!created) {
throw new Error('创建通知目标失败')
}
return created
}
export async function listExternalNotificationTargets() {
await ensureExternalNotificationTables()
const rows = await queryRaw<any>(
`
SELECT
id,
name,
callback_url AS "callbackUrl",
is_active AS "isActive",
auth_type AS "authType",
auth_config AS "authConfig",
event_types AS "eventTypes",
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM "${TARGET_TABLE}"
ORDER BY created_at ASC, name ASC
`,
)
return rows.map((row) => normalizeTargetRow(row))
}
export async function updateExternalNotificationTarget(
targetId: string,
input: Partial<ExternalNotificationTargetInput>,
) {
await ensureExternalNotificationTables()
ensureWorkerStarted()
const existing = await getExternalNotificationTargetById(targetId)
if (!existing) {
throw new Error('通知目标不存在')
}
const normalized = normalizeTargetInput(
{
name: input.name ?? existing.name,
callbackUrl: input.callbackUrl ?? existing.callbackUrl,
isActive: typeof input.isActive === 'boolean' ? input.isActive : existing.isActive,
authType: input.authType ?? existing.authType,
authConfig: input.authConfig ?? existing.authConfig,
eventTypes: input.eventTypes ?? existing.eventTypes,
},
existing,
)
await executeRaw(
`
UPDATE "${TARGET_TABLE}"
SET
name = $2,
callback_url = $3,
is_active = $4,
auth_type = $5,
auth_config = $6::jsonb,
event_types = $7::jsonb,
updated_at = NOW()
WHERE id = $1
`,
targetId,
normalized.name,
normalized.callbackUrl,
normalized.isActive,
normalized.authType,
asJson(normalized.authConfig, '{}'),
asJson(normalized.eventTypes, '[]'),
)
const updated = await getExternalNotificationTargetById(targetId)
if (!updated) {
throw new Error('更新通知目标失败')
}
return updated
}
export async function listExternalNotificationDeliveries(options?: {
status?: string
targetId?: string
materialId?: string
limit?: number
}) {
await ensureExternalNotificationTables()
const conditions = ['1 = 1']
const params: any[] = []
if (options?.status) {
params.push(String(options.status).trim())
conditions.push(`d.status = $${params.length}`)
}
if (options?.targetId) {
params.push(String(options.targetId).trim())
conditions.push(`d.target_id = $${params.length}`)
}
if (options?.materialId) {
params.push(String(options.materialId).trim())
conditions.push(`d.material_id = $${params.length}`)
}
const limit = Number.isFinite(options?.limit) && Number(options?.limit) > 0
? Math.min(Number(options?.limit), 200)
: 100
params.push(limit)
const rows = await queryRaw<any>(
`
SELECT
d.id,
d.target_id AS "targetId",
t.name AS "targetName",
d.event_type AS "eventType",
d.material_id AS "materialId",
d.material_type AS "materialType",
d.task_no AS "taskNo",
d.status,
d.attempt_count AS "attemptCount",
d.max_attempts AS "maxAttempts",
d.next_retry_at AS "nextRetryAt",
d.last_status_code AS "lastStatusCode",
d.last_response_body AS "lastResponseBody",
d.last_error AS "lastError",
d.payload,
d.created_at AS "createdAt",
d.updated_at AS "updatedAt",
d.completed_at AS "completedAt"
FROM "${DELIVERY_TABLE}" d
LEFT JOIN "${TARGET_TABLE}" t ON t.id = d.target_id
WHERE ${conditions.join(' AND ')}
ORDER BY d.created_at DESC
LIMIT $${params.length}
`,
...params,
)
return rows.map((row) => normalizeDeliveryRow(row))
}
async function executeRaw(sql: string, ...params: any[]) {
return prisma.$executeRawUnsafe(sql, ...params)
}
async function queryRaw<T>(sql: string, ...params: any[]) {
return prisma.$queryRawUnsafe(sql, ...params) as Promise<T[]>
}
export async function ensureExternalNotificationTables() {
if (isTableInitialized) {
return
}
await executeRaw(`
CREATE TABLE IF NOT EXISTS "${TARGET_TABLE}" (
"id" TEXT PRIMARY KEY,
"name" TEXT NOT NULL,
"callback_url" TEXT NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT TRUE,
"auth_type" TEXT NOT NULL DEFAULT 'none',
"auth_config" JSONB NOT NULL DEFAULT '{}'::jsonb,
"event_types" JSONB NOT NULL DEFAULT '[]'::jsonb,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`)
await executeRaw(`
CREATE TABLE IF NOT EXISTS "${DELIVERY_TABLE}" (
"id" TEXT PRIMARY KEY,
"target_id" TEXT NOT NULL,
"event_type" TEXT NOT NULL,
"material_id" TEXT NOT NULL,
"material_type" TEXT NOT NULL,
"task_no" TEXT,
"status" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"attempt_count" INT NOT NULL DEFAULT 0,
"max_attempts" INT NOT NULL DEFAULT ${DELIVERY_MAX_ATTEMPTS},
"next_retry_at" TIMESTAMP(3),
"last_status_code" INT,
"last_response_body" TEXT,
"last_error" TEXT,
"dispatched_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`)
await executeRaw(`
CREATE UNIQUE INDEX IF NOT EXISTS "external_notification_targets_name_key"
ON "${TARGET_TABLE}" ("name")
`)
await executeRaw(`
CREATE UNIQUE INDEX IF NOT EXISTS "external_notification_deliveries_target_event_material_key"
ON "${DELIVERY_TABLE}" ("target_id", "event_type", "material_id")
`)
await executeRaw(`
CREATE INDEX IF NOT EXISTS "external_notification_deliveries_status_next_retry_idx"
ON "${DELIVERY_TABLE}" ("status", "next_retry_at")
`)
isTableInitialized = true
}
async function recoverDueDeliveries(limit = 20) {
await ensureExternalNotificationTables()
const rows = await queryRaw<{ id: string }>(
`
SELECT d.id
FROM "${DELIVERY_TABLE}" d
INNER JOIN "${TARGET_TABLE}" t ON t.id = d.target_id
WHERE t.is_active = TRUE
AND (
(d.status IN ('pending', 'retrying') AND (d.next_retry_at IS NULL OR d.next_retry_at <= NOW()))
OR
(d.status = 'processing' AND d.updated_at < NOW() - INTERVAL '10 minutes')
)
ORDER BY d.created_at ASC
LIMIT $1
`,
limit,
)
rows.forEach((row) => scheduleDeliveryProcessing(String(row.id), 0))
}
function ensureWorkerStarted() {
if (isWorkerStarted) {
return
}
isWorkerStarted = true
const bootstrapTimer = setTimeout(() => {
void recoverDueDeliveries()
}, 1000)
bootstrapTimer.unref?.()
recoveryTimer = setInterval(() => {
void recoverDueDeliveries()
}, RECOVERY_INTERVAL_MS)
recoveryTimer.unref?.()
}
function scheduleDeliveryProcessing(deliveryId: string, delayMs: number) {
const existingTimer = deliveryTimers.get(deliveryId)
if (existingTimer) {
clearTimeout(existingTimer)
}
const timer = setTimeout(() => {
deliveryTimers.delete(deliveryId)
void processExternalNotificationDelivery(deliveryId)
}, Math.max(0, delayMs))
timer.unref?.()
deliveryTimers.set(deliveryId, timer)
}
function buildNotificationHeaders(target: ExternalNotificationTarget) {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (target.authType === AUTO_LISTING_AUTH_TYPE.BEARER) {
const token = String(target.authConfig.token || '').trim()
if (token) {
headers.Authorization = `Bearer ${token}`
}
}
if (target.authType === AUTO_LISTING_AUTH_TYPE.HEADER) {
const headerName = String(target.authConfig.headerName || '').trim()
const headerValue = String(target.authConfig.headerValue || '').trim()
if (headerName && headerValue) {
headers[headerName] = headerValue
}
}
return headers
}
function truncateResponseBody(body: string | null | undefined) {
const normalized = String(body || '')
if (!normalized) {
return null
}
return normalized.length > DELIVERY_RESPONSE_BODY_LIMIT
? normalized.slice(0, DELIVERY_RESPONSE_BODY_LIMIT)
: normalized
}
async function buildTaskDetailNotificationPayload(materialId: string) {
const material = await prisma.material.findFirst({
where: {
id: materialId,
deletedAt: null,
materialType: '任务-详情',
isFullSet: true,
},
select: {
id: true,
taskNo: true,
productModel: true,
categoryName: true,
sceneName: true,
materialType: true,
previewUrl: true,
isFullSet: true,
createdAt: true,
assets: {
where: {
assetType: {
in: ['main-preview', 'sku-preview', 'detail-preview'],
},
},
orderBy: { sortOrder: 'asc' },
select: {
id: true,
assetType: true,
fileUrl: true,
fileName: true,
sortOrder: true,
},
},
},
})
if (!material) {
return null
}
const mapPreviewAssets = (assetType: 'main-preview' | 'sku-preview' | 'detail-preview') =>
material.assets
.filter((asset: any) => asset.assetType === assetType)
.sort((left: any, right: any) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0))
.map((asset: any) => ({
id: String(asset.id),
url: String(asset.fileUrl || ''),
fileName: String(asset.fileName || ''),
sortOrder: Number(asset.sortOrder || 0),
}))
return {
eventId: randomUUID(),
eventType: AUTO_LISTING_EVENT_TYPE,
materialId: material.id,
materialType: material.materialType,
taskNo: material.taskNo,
productModel: material.productModel,
categoryName: material.categoryName,
shopName: material.categoryName,
sceneName: material.sceneName,
isFullSet: Boolean(material.isFullSet),
occurredAt: material.createdAt.toISOString(),
pullUrl: `${getPublicAppOrigin()}/api/external/product-assets?materialId=${material.id}`,
coverPreviewUrl: material.previewUrl || null,
previewAssets: {
main: mapPreviewAssets('main-preview'),
sku: mapPreviewAssets('sku-preview'),
detail: mapPreviewAssets('detail-preview'),
},
}
}
export async function queueTaskDetailFullSetNotification(materialId: string) {
await ensureExternalNotificationTables()
ensureWorkerStarted()
const payload = await buildTaskDetailNotificationPayload(materialId)
if (!payload) {
return []
}
const targets = await queryRaw<any>(
`
SELECT
id,
name,
callback_url AS "callbackUrl",
is_active AS "isActive",
auth_type AS "authType",
auth_config AS "authConfig",
event_types AS "eventTypes",
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM "${TARGET_TABLE}"
WHERE is_active = TRUE
AND event_types ? $1
ORDER BY created_at ASC
`,
AUTO_LISTING_EVENT_TYPE,
)
const createdIds: string[] = []
for (const row of targets) {
const deliveryId = randomUUID()
const insertedRows = await queryRaw<{ id: string }>(
`
INSERT INTO "${DELIVERY_TABLE}" (
id,
target_id,
event_type,
material_id,
material_type,
task_no,
status,
payload,
attempt_count,
max_attempts,
next_retry_at,
created_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 0, $9, NOW(), NOW(), NOW())
ON CONFLICT ("target_id", "event_type", "material_id") DO NOTHING
RETURNING id
`,
deliveryId,
String(row.id),
AUTO_LISTING_EVENT_TYPE,
payload.materialId,
payload.materialType,
payload.taskNo,
AUTO_LISTING_DELIVERY_STATUS.PENDING,
asJson(payload, '{}'),
DELIVERY_MAX_ATTEMPTS,
)
const inserted = insertedRows[0]
if (inserted?.id) {
createdIds.push(String(inserted.id))
scheduleDeliveryProcessing(String(inserted.id), 0)
}
}
return createdIds
}
export async function retryExternalNotificationDelivery(deliveryId: string) {
await ensureExternalNotificationTables()
ensureWorkerStarted()
await executeRaw(
`
UPDATE "${DELIVERY_TABLE}"
SET
status = $2,
next_retry_at = NOW(),
completed_at = NULL,
updated_at = NOW()
WHERE id = $1
`,
deliveryId,
AUTO_LISTING_DELIVERY_STATUS.PENDING,
)
scheduleDeliveryProcessing(deliveryId, 0)
}
export async function processExternalNotificationDelivery(
deliveryId: string,
options?: { force?: boolean },
) {
await ensureExternalNotificationTables()
ensureWorkerStarted()
const claimed = await prisma.$transaction(async (tx: any) => {
await tx.$executeRawUnsafe('SELECT pg_advisory_xact_lock(hashtext($1))', deliveryId)
const rows = await tx.$queryRawUnsafe(
`
SELECT
d.id,
d.status,
d.payload,
d.attempt_count AS "attemptCount",
d.max_attempts AS "maxAttempts",
d.updated_at AS "updatedAt",
t.id AS "targetId",
t.name,
t.callback_url AS "callbackUrl",
t.is_active AS "isActive",
t.auth_type AS "authType",
t.auth_config AS "authConfig",
t.event_types AS "eventTypes",
t.created_at AS "createdAt",
t.updated_at AS "targetUpdatedAt"
FROM "${DELIVERY_TABLE}" d
INNER JOIN "${TARGET_TABLE}" t ON t.id = d.target_id
WHERE d.id = $1
LIMIT 1
`,
deliveryId,
)
const row = rows[0]
if (!row || !Boolean(row.isActive)) {
return null
}
const updatedAt = new Date(String(row.updatedAt || ''))
const isStaleProcessing =
row.status === AUTO_LISTING_DELIVERY_STATUS.PROCESSING &&
!Number.isNaN(updatedAt.getTime()) &&
Date.now() - updatedAt.getTime() > STALE_PROCESSING_MS
if (
!options?.force &&
row.status === AUTO_LISTING_DELIVERY_STATUS.PROCESSING &&
!isStaleProcessing
) {
return null
}
if (
!options?.force &&
row.status !== AUTO_LISTING_DELIVERY_STATUS.PENDING &&
row.status !== AUTO_LISTING_DELIVERY_STATUS.RETRYING &&
!isStaleProcessing
) {
return null
}
await tx.$executeRawUnsafe(
`
UPDATE "${DELIVERY_TABLE}"
SET
status = $2,
updated_at = NOW()
WHERE id = $1
`,
deliveryId,
AUTO_LISTING_DELIVERY_STATUS.PROCESSING,
)
return {
attemptCount: Number(row.attemptCount || 0),
maxAttempts: Number(row.maxAttempts || DELIVERY_MAX_ATTEMPTS),
payload: row.payload,
target: normalizeTargetRow({
id: row.targetId,
name: row.name,
callbackUrl: row.callbackUrl,
isActive: row.isActive,
authType: row.authType,
authConfig: row.authConfig,
eventTypes: row.eventTypes,
createdAt: row.createdAt,
updatedAt: row.targetUpdatedAt,
}),
}
})
if (!claimed) {
return null
}
let responseStatus: number | null = null
let responseText: string | null = null
let requestError: string | null = null
try {
const response = await fetch(claimed.target.callbackUrl, {
method: 'POST',
headers: buildNotificationHeaders(claimed.target),
body: JSON.stringify(claimed.payload),
})
responseStatus = response.status
responseText = truncateResponseBody(await response.text())
if (response.ok) {
await executeRaw(
`
UPDATE "${DELIVERY_TABLE}"
SET
status = $2,
attempt_count = attempt_count + 1,
last_status_code = $3,
last_response_body = $4,
last_error = NULL,
next_retry_at = NULL,
dispatched_at = COALESCE(dispatched_at, NOW()),
completed_at = NOW(),
updated_at = NOW()
WHERE id = $1
`,
deliveryId,
AUTO_LISTING_DELIVERY_STATUS.SUCCEEDED,
responseStatus,
responseText,
)
return {
success: true,
status: AUTO_LISTING_DELIVERY_STATUS.SUCCEEDED,
}
}
requestError = `HTTP ${response.status}`
} catch (error) {
requestError = error instanceof Error ? error.message : '通知请求失败'
}
const nextAttempt = claimed.attemptCount + 1
const isExhausted = nextAttempt >= claimed.maxAttempts
const retryDelayMs = RETRY_DELAYS_MS[Math.min(nextAttempt, RETRY_DELAYS_MS.length - 1)]
const nextRetryAt = new Date(Date.now() + retryDelayMs).toISOString()
await executeRaw(
`
UPDATE "${DELIVERY_TABLE}"
SET
status = $2,
attempt_count = attempt_count + 1,
last_status_code = $3,
last_response_body = $4,
last_error = $5,
next_retry_at = $6,
completed_at = $7,
updated_at = NOW()
WHERE id = $1
`,
deliveryId,
isExhausted ? AUTO_LISTING_DELIVERY_STATUS.EXHAUSTED : AUTO_LISTING_DELIVERY_STATUS.RETRYING,
responseStatus,
responseText,
requestError,
isExhausted ? null : nextRetryAt,
isExhausted ? new Date().toISOString() : null,
)
if (!isExhausted) {
scheduleDeliveryProcessing(deliveryId, retryDelayMs)
}
return {
success: false,
status: isExhausted ? AUTO_LISTING_DELIVERY_STATUS.EXHAUSTED : AUTO_LISTING_DELIVERY_STATUS.RETRYING,
}
}