import { Prisma } from "@prisma/client"; import { QUOTE_VALIDITY_MS } from "@/lib/constants/quote"; import { getFlockMinQuotes } from "@/lib/flock/env"; import { isFlockNonRetryableFailure } from "@/lib/flock/exclusive-lock"; import { prisma } from "@/lib/prisma"; import { getMarkupRule } from "@/modules/pricing/engine"; import { getConfidenceScore } from "@/modules/quote/confidence"; import { handleRpaFailure } from "@/modules/quote/fallback-orchestrator"; import type { FlockQuoteJobData } from "@/modules/flock/rpa-queue"; import { buildFlockStoredQuotes } from "@/modules/flock/quote-storage"; import { RpaError } from "@/modules/rpa/errors"; import { isProviderLoginFailureMessage, PROVIDER_LOGIN_FAILED_USER_MESSAGE, } from "@/modules/rpa/provider-login-message"; import { recordPostDone, safeRecord } from "@/modules/metrics/collector"; import { closeCircuit, isCircuitOpen, recordRpaFailure, recordRpaSuccess, } from "@/modules/cache/circuit-breaker"; import { runFlockQuoteRpa } from "@/workers/rpa/flock/run-quote"; import { heartbeat, isWorkerPaused } from "@/workers/rpa/worker-state"; import { buildFlockProgressPayload, mapFlockErrorToStage, parseFlockProgress, shouldOverwriteFlockFailureStage, type FlockRpaStage, } from "@/lib/flock/rpa-progress"; async function writeFlockRpaStage( quoteId: string, stage: FlockRpaStage, ): Promise { try { await prisma.quoteRecord.update({ where: { quoteId }, data: { quotesJson: buildFlockProgressPayload(stage) as unknown as Prisma.InputJsonValue, }, }); } catch (error) { console.warn( `[flock-job] 写进度失败 stage=${stage}:`, error instanceof Error ? error.message : error, ); } } async function readFlockProgressStage( quoteId: string, ): Promise { try { const row = await prisma.quoteRecord.findUnique({ where: { quoteId }, select: { quotesJson: true }, }); return parseFlockProgress(row?.quotesJson)?.stage ?? null; } catch { return null; } } export async function processFlockQuoteJob( data: FlockQuoteJobData, workerId: string, options?: { isFinalAttempt?: boolean }, ): Promise { await heartbeat(workerId); if (await isCircuitOpen()) { throw new RpaError("PAGE_LOAD_TIMEOUT", "RPA 熔断中,暂缓消费", { retryable: true, }); } if (await isWorkerPaused(workerId)) { throw new RpaError("RPA_CAPTCHA", "Worker 已暂停", { retryable: true }); } try { // 每轮(含 BullMQ 重试)从互斥锁阶段开始;勿在重试时把阶段回退逻辑搞乱——这里确实要重新抢锁 await writeFlockRpaStage(data.quoteId, "lock"); const result = await runFlockQuoteRpa(data.input, { customerId: data.customerId, onStage: (stage) => writeFlockRpaStage(data.quoteId, stage), }); const minQuotes = getFlockMinQuotes(); if (!result.ok || result.quotes.length < minQuotes) { const msg = result.errorMessage ?? "Flock 报价提取失败"; if (isProviderLoginFailureMessage(msg)) { throw new RpaError( "PROVIDER_LOGIN_FAILED", PROVIDER_LOGIN_FAILED_USER_MESSAGE, { retryable: false }, ); } // 上限/拒号/互斥忙:禁止 BullMQ 二次空烧(否则加重 IP 风控且拖慢 embed) throw new RpaError("RPA_DATA_INVALID", msg, { retryable: !isFlockNonRetryableFailure(msg), }); } const markupRule = await getMarkupRule(data.customerId); const storedQuotes = buildFlockStoredQuotes( result.quotes, markupRule, result.reference, ); const validUntil = new Date(Date.now() + QUOTE_VALIDITY_MS); await prisma.quoteRecord.update({ where: { quoteId: data.quoteId }, data: { status: "done", sourceType: "rpa", isRealtime: true, confidenceScore: getConfidenceScore("rpa"), markupPercent: markupRule.type === "percent" ? markupRule.percent : 0, quotesJson: storedQuotes as unknown as Prisma.InputJsonValue, validUntil, errorCode: null, errorMessage: null, }, }); safeRecord(() => recordPostDone({ quoteId: data.quoteId, sourceType: "rpa", isRealtime: true, }), ); await recordRpaSuccess(); await closeCircuit(); } catch (error) { await recordRpaFailure(); const rawMsg = error instanceof Error ? error.message : String(error); const mapped = mapFlockErrorToStage(rawMsg); const currentStage = await readFlockProgressStage(data.quoteId); if (shouldOverwriteFlockFailureStage(currentStage, mapped)) { await writeFlockRpaStage(data.quoteId, mapped.stage); } else if (currentStage) { console.warn( `[flock-job] 保留进度 stage=${currentStage}(不因未匹配错误回退到 ${mapped.stage})err=${rawMsg.slice(0, 120)}`, ); } const isFinalAttempt = options?.isFinalAttempt ?? true; const canRetry = error instanceof RpaError && error.retryable && !isFinalAttempt; if (canRetry) throw error; await handleRpaFailure( data.quoteId, data.cargoHash, data.customerId, error, { skipStaleFallback: true }, ); if (error instanceof RpaError && !error.retryable) return; throw error; } }