1173 lines
32 KiB
TypeScript
1173 lines
32 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 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<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 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<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((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<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
|
|
`,
|
|
eventType,
|
|
)
|
|
}
|
|
|
|
async function enqueueNotificationDeliveries(options: {
|
|
eventType: string
|
|
materialId: string
|
|
materialType: string
|
|
taskNo?: string | null
|
|
dedupeKey: string
|
|
payload: Record<string, unknown>
|
|
}) {
|
|
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 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,
|
|
"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<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'),
|
|
},
|
|
}
|
|
}
|
|
|
|
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<string, string>()
|
|
const latestMetaByBarcode = new Map<string, { createdAt: Date; sortOrder: number }>()
|
|
|
|
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,
|
|
}
|
|
}
|