|
|
import { Prisma } from "@prisma/client";
|
|
|
import { QUOTE_VALIDITY_MS } from "@/lib/constants/quote";
|
|
|
import { CAPTCHA_PAUSE_MINUTES } from "@/lib/constants/rpa";
|
|
|
import { prepareMotherShipStorageQuotes } from "@/modules/quote/quote-completeness";
|
|
|
import { detectDeviation } from "@/modules/alert/deviation-detector";
|
|
|
import { writeAlert } from "@/modules/alert/service";
|
|
|
import {
|
|
|
closeCircuit,
|
|
|
isCircuitOpen,
|
|
|
recordRpaFailure,
|
|
|
recordRpaSuccess,
|
|
|
} from "@/modules/cache/circuit-breaker";
|
|
|
import { overwriteL1, setL2, setL3 } from "@/modules/cache/redis-cache";
|
|
|
import { prisma } from "@/lib/prisma";
|
|
|
import { applyMarkupToQuotes, getMarkupRule } from "@/modules/pricing/engine";
|
|
|
import { getConfidenceScore } from "@/modules/quote/confidence";
|
|
|
import { handleRpaFailure } from "@/modules/quote/fallback-orchestrator";
|
|
|
import type { QuoteJobData } from "@/modules/quote/rpa-queue";
|
|
|
import { NO_RETRY_CODES, RpaError } from "@/modules/rpa/errors";
|
|
|
import { isEntryErrorCode } from "@/modules/rpa/error-mapper";
|
|
|
import {
|
|
|
cargoToQuoteRequest,
|
|
|
quoteItemsToRawTiers,
|
|
|
} from "@/modules/rpa/quote-mapper";
|
|
|
import { recordPostDone, safeRecord } from "@/modules/metrics/collector";
|
|
|
import { recordQuoteQueryOutcome } from "@/modules/quote/query-log";
|
|
|
import { mothershipProvider } from "@/workers/rpa/mothership";
|
|
|
import {
|
|
|
heartbeat,
|
|
|
isWorkerPaused,
|
|
|
pauseWorker,
|
|
|
} from "@/workers/rpa/worker-state";
|
|
|
|
|
|
async function writeRpaAlert(
|
|
|
alertType: "RPA_CAPTCHA" | "STRUCT_CHANGE" | "SESSION_EXPIRED",
|
|
|
quoteId: string,
|
|
|
cargoHash: string,
|
|
|
detail: Record<string, unknown>,
|
|
|
): Promise<void> {
|
|
|
await writeAlert(alertType, { quoteId, cargoHash, detail });
|
|
|
}
|
|
|
|
|
|
async function completeQuoteSuccess(
|
|
|
data: QuoteJobData,
|
|
|
rawQuotes: ReturnType<typeof quoteItemsToRawTiers>,
|
|
|
): Promise<void> {
|
|
|
const uiQuotes = prepareMotherShipStorageQuotes(rawQuotes);
|
|
|
const markupRule = await getMarkupRule(data.customerId);
|
|
|
const markedQuotes = applyMarkupToQuotes(uiQuotes, markupRule);
|
|
|
const validUntil = new Date(Date.now() + QUOTE_VALIDITY_MS);
|
|
|
const cachePayload = { quotes: uiQuotes };
|
|
|
|
|
|
await setL2(data.cargoHash, cachePayload);
|
|
|
await setL3(data.cargoHash, cachePayload);
|
|
|
|
|
|
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: markedQuotes as unknown as Prisma.InputJsonValue,
|
|
|
validUntil,
|
|
|
errorCode: null,
|
|
|
},
|
|
|
});
|
|
|
|
|
|
await detectDeviation(data.cargoHash, uiQuotes, data.quoteId);
|
|
|
|
|
|
const doneResponse = {
|
|
|
quote_id: data.quoteId,
|
|
|
status: "done" as const,
|
|
|
source_type: "rpa" as const,
|
|
|
is_realtime: true,
|
|
|
};
|
|
|
await overwriteL1(data.requestId, {
|
|
|
quoteId: data.quoteId,
|
|
|
response: doneResponse,
|
|
|
});
|
|
|
|
|
|
await recordQuoteQueryOutcome(data.quoteId, "success", {
|
|
|
sourceType: "rpa",
|
|
|
tierCount: markedQuotes.length,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
/** 单条 RPA job 处理(task-029) */
|
|
|
export async function processQuoteJob(
|
|
|
data: QuoteJobData,
|
|
|
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 {
|
|
|
const result = await mothershipProvider.getQuote(
|
|
|
cargoToQuoteRequest(data.cargo),
|
|
|
);
|
|
|
const rawQuotes = quoteItemsToRawTiers(result.items);
|
|
|
await completeQuoteSuccess(data, rawQuotes);
|
|
|
safeRecord(() =>
|
|
|
recordPostDone({
|
|
|
quoteId: data.quoteId,
|
|
|
sourceType: "rpa",
|
|
|
isRealtime: true,
|
|
|
}),
|
|
|
);
|
|
|
await recordRpaSuccess();
|
|
|
await closeCircuit();
|
|
|
} catch (error) {
|
|
|
if (error instanceof RpaError) {
|
|
|
if (error.pauseWorker) {
|
|
|
await pauseWorker(workerId, CAPTCHA_PAUSE_MINUTES);
|
|
|
await writeRpaAlert("RPA_CAPTCHA", data.quoteId, data.cargoHash, {
|
|
|
worker_id: workerId,
|
|
|
message: error.message,
|
|
|
});
|
|
|
} else if (error.code === "STRUCT_CHANGE") {
|
|
|
await writeRpaAlert("STRUCT_CHANGE", data.quoteId, data.cargoHash, {
|
|
|
message: error.message,
|
|
|
});
|
|
|
} else if (error.code === "QUOTE_ENTRY_UNAVAILABLE") {
|
|
|
await writeRpaAlert("STRUCT_CHANGE", data.quoteId, data.cargoHash, {
|
|
|
message: error.message,
|
|
|
entry_error: "QUOTE_ENTRY_UNAVAILABLE",
|
|
|
});
|
|
|
} else if (error.code === "SESSION_EXPIRED") {
|
|
|
await writeRpaAlert("SESSION_EXPIRED", data.quoteId, data.cargoHash, {
|
|
|
message: error.message,
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const shouldCountFailure = !(
|
|
|
error instanceof RpaError &&
|
|
|
(NO_RETRY_CODES.has(error.code) || isEntryErrorCode(error.code))
|
|
|
);
|
|
|
if (shouldCountFailure) {
|
|
|
await recordRpaFailure();
|
|
|
}
|
|
|
|
|
|
const isFinalAttempt = options?.isFinalAttempt ?? true;
|
|
|
const canRetry =
|
|
|
error instanceof RpaError &&
|
|
|
error.retryable &&
|
|
|
!isEntryErrorCode(error.code) &&
|
|
|
!isFinalAttempt;
|
|
|
|
|
|
if (canRetry) {
|
|
|
throw error;
|
|
|
}
|
|
|
|
|
|
await handleRpaFailure(
|
|
|
data.quoteId,
|
|
|
data.cargoHash,
|
|
|
data.customerId,
|
|
|
error,
|
|
|
);
|
|
|
|
|
|
if (error instanceof RpaError && !error.retryable) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
throw error;
|
|
|
}
|
|
|
}
|