|
|
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<void> {
|
|
|
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<string | null> {
|
|
|
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<void> {
|
|
|
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;
|
|
|
}
|
|
|
}
|