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 TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE = 'task-detail.barcode-images.updated' 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 eventTypes: string[] createdAt: string updatedAt: string } export interface ExternalNotificationTargetAdminView extends Omit { authConfig: Record authConfigured: boolean authMasked: 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 | null createdAt: string updatedAt: string completedAt: string | null } export interface ExternalNotificationTargetInput { name: string callbackUrl: string isActive?: boolean authType?: string authConfig?: Record eventTypes?: string[] } const TARGET_TABLE = 'external_notification_targets' const DELIVERY_TABLE = 'external_notification_deliveries' const OLD_DELIVERY_UNIQUE_INDEX = 'external_notification_deliveries_target_event_material_key' const DELIVERY_DEDUPE_UNIQUE_INDEX = 'external_notification_deliveries_target_event_dedupe_key' 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] const SUPPORTED_EVENT_TYPES = [ AUTO_LISTING_EVENT_TYPE, TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE, ] as const let isTableInitialized = false let isWorkerStarted = false let recoveryTimer: NodeJS.Timeout | null = null const deliveryTimers = new Map() 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 { if (!input || typeof input !== 'object' || Array.isArray(input)) { return {} } return Object.entries(input as Record).reduce>((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((item): item is (typeof SUPPORTED_EVENT_TYPES)[number] => Boolean(item) && (SUPPORTED_EVENT_TYPES as readonly string[]).includes(item), ) : [] return normalized.length > 0 ? Array.from(new Set(normalized)) : [AUTO_LISTING_EVENT_TYPE] } function buildBarcodeLookupUrl(barcode: string) { return `${getPublicAppOrigin()}/api/external/product-barcode-images?barcode=${encodeURIComponent(barcode)}` } function compareBarcodeTimeline( left: { createdAt: Date; sortOrder: number }, right: { createdAt: Date; sortOrder: number }, ) { const createdAtDiff = right.createdAt.getTime() - left.createdAt.getTime() if (createdAtDiff !== 0) { return createdAtDiff } return left.sortOrder - right.sortOrder } async function listActiveTargetsByEventType(eventType: string) { return queryRaw( ` 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 `, eventType, ) } async function enqueueNotificationDeliveries(options: { eventType: string materialId: string materialType: string taskNo?: string | null dedupeKey: string payload: Record }) { const targets = await listActiveTargetsByEventType(options.eventType) 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, dedupe_key, status, payload, attempt_count, max_attempts, next_retry_at, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, 0, $10, NOW(), NOW(), NOW()) ON CONFLICT ("target_id", "event_type", "dedupe_key") DO NOTHING RETURNING id `, deliveryId, String(row.id), options.eventType, options.materialId, options.materialType, options.taskNo || null, options.dedupeKey, AUTO_LISTING_DELIVERY_STATUS.PENDING, asJson(options.payload, '{}'), DELIVERY_MAX_ATTEMPTS, ) const inserted = insertedRows[0] if (inserted?.id) { createdIds.push(String(inserted.id)) scheduleDeliveryProcessing(String(inserted.id), 0) } } return createdIds } 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 maskSecretForAdminSummary(value: string | null | undefined) { const normalized = String(value || '').trim() if (!normalized) return '未配置' if (normalized.length <= 8) return '已配置' return `${normalized.slice(0, 4)}****${normalized.slice(-4)}` } export function buildExternalNotificationTargetAdminView( target: ExternalNotificationTarget, ): ExternalNotificationTargetAdminView { const authConfig = normalizeAuthConfig(target.authConfig) if (target.authType === AUTO_LISTING_AUTH_TYPE.BEARER) { const token = String(authConfig.token || '').trim() return { ...target, authConfig: { token: '' }, authConfigured: Boolean(token), authMasked: `Bearer · ${maskSecretForAdminSummary(token)}`, } } if (target.authType === AUTO_LISTING_AUTH_TYPE.HEADER) { const headerName = String(authConfig.headerName || '').trim() const headerValue = String(authConfig.headerValue || '').trim() return { ...target, authConfig: { ...(headerName ? { headerName } : {}), headerValue: '', }, authConfigured: Boolean(headerName && headerValue), authMasked: headerName ? `Header · ${headerName} · ${maskSecretForAdminSummary(headerValue)}` : 'Header · 未配置', } } return { ...target, authConfig: {}, authConfigured: false, authMasked: '无鉴权', } } 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) : 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, } } export async function getExternalNotificationTargetById(targetId: string) { const rows = await queryRaw( ` 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( ` 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, ) { 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( ` 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(sql: string, ...params: any[]) { return prisma.$queryRawUnsafe(sql, ...params) as Promise } 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, "dedupe_key" 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(` ALTER TABLE "${DELIVERY_TABLE}" ADD COLUMN IF NOT EXISTS "dedupe_key" TEXT `) await executeRaw(` UPDATE "${DELIVERY_TABLE}" SET "dedupe_key" = COALESCE("dedupe_key", "material_id") WHERE "dedupe_key" IS NULL `) await executeRaw(` DROP INDEX IF EXISTS "${OLD_DELIVERY_UNIQUE_INDEX}" `) await executeRaw(` CREATE UNIQUE INDEX IF NOT EXISTS "${DELIVERY_DEDUPE_UNIQUE_INDEX}" ON "${DELIVERY_TABLE}" ("target_id", "event_type", "dedupe_key") `) 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 = { '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'), }, } } async function buildTaskDetailBarcodeNotificationPayload(materialId: string, assetIds: string[]) { const normalizedAssetIds = Array.from(new Set(assetIds.map((assetId) => String(assetId || '').trim()).filter(Boolean))) if (normalizedAssetIds.length === 0) { return null } const material = await prisma.material.findFirst({ where: { id: materialId, deletedAt: null, materialType: '任务-详情', }, select: { id: true, taskNo: true, productModel: true, categoryName: true, sceneName: true, materialType: true, authorName: true, createdAt: true, user: { select: { realName: true, username: true, }, }, assets: { where: { id: { in: normalizedAssetIds }, assetType: 'sku-preview', }, orderBy: [ { createdAt: 'desc' }, { sortOrder: 'asc' }, ], select: { id: true, fileUrl: true, fileName: true, sortOrder: true, skuCode: true, createdAt: true, updatedAt: true, barcodes: { select: { barcode: true, }, }, }, }, }, }) if (!material || material.assets.length === 0) { return null } const barcodes = Array.from( new Set( material.assets.flatMap((asset: any) => (asset.barcodes || []).map((item: any) => String(item.barcode || '').trim()).filter(Boolean), ), ), ) const latestBarcodeRecords = await prisma.materialAssetBarcode.findMany({ where: { barcode: { in: barcodes }, asset: { assetType: 'sku-preview', material: { deletedAt: null, materialType: '任务-详情', }, }, }, select: { barcode: true, assetId: true, asset: { select: { createdAt: true, sortOrder: true, }, }, }, }) const latestAssetByBarcode = new Map() const latestMetaByBarcode = new Map() for (const record of latestBarcodeRecords) { const barcode = String(record.barcode || '').trim() if (!barcode || !record.asset) { continue } const nextMeta = { createdAt: record.asset.createdAt, sortOrder: Number(record.asset.sortOrder || 0), } const currentMeta = latestMetaByBarcode.get(barcode) if (!currentMeta || compareBarcodeTimeline(nextMeta, currentMeta) < 0) { latestMetaByBarcode.set(barcode, nextMeta) latestAssetByBarcode.set(barcode, String(record.assetId)) } } const uploaderName = material.user?.realName || material.user?.username || material.authorName || '' const items = material.assets.flatMap((asset: any) => { const assetBarcodes: string[] = Array.from( new Set((asset.barcodes || []).map((item: any) => String(item.barcode || '').trim()).filter(Boolean)), ) return assetBarcodes.map((barcode) => ({ materialId: material.id, assetId: String(asset.id), skuCode: asset.skuCode || null, barcode, previewUrl: String(asset.fileUrl || ''), fileName: String(asset.fileName || ''), sortOrder: Number(asset.sortOrder || 0), uploadedAt: asset.createdAt.toISOString(), updatedAt: asset.updatedAt.toISOString(), uploaderName, isLatest: latestAssetByBarcode.get(barcode) === String(asset.id), lookupUrl: buildBarcodeLookupUrl(barcode), })) }) return { eventId: randomUUID(), eventType: TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE, materialId: material.id, materialType: material.materialType, taskNo: material.taskNo, productModel: material.productModel, categoryName: material.categoryName, shopName: material.categoryName, sceneName: material.sceneName, occurredAt: new Date().toISOString(), items, } } export async function queueTaskDetailFullSetNotification(materialId: string) { await ensureExternalNotificationTables() ensureWorkerStarted() const payload = await buildTaskDetailNotificationPayload(materialId) if (!payload) { return [] } return enqueueNotificationDeliveries({ eventType: AUTO_LISTING_EVENT_TYPE, materialId: payload.materialId, materialType: payload.materialType, taskNo: payload.taskNo, dedupeKey: payload.materialId, payload, }) } export async function queueTaskDetailBarcodeImagesUpdatedNotification( materialId: string, assetIds: string[], ) { await ensureExternalNotificationTables() ensureWorkerStarted() const normalizedAssetIds = Array.from(new Set(assetIds.map((assetId) => String(assetId || '').trim()).filter(Boolean))) const payload = await buildTaskDetailBarcodeNotificationPayload(materialId, normalizedAssetIds) if (!payload || payload.items.length === 0) { return [] } return enqueueNotificationDeliveries({ eventType: TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE, materialId: payload.materialId, materialType: payload.materialType, taskNo: payload.taskNo, dedupeKey: payload.eventId, payload, }) } 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, } }