|
|
import { config } from "dotenv";
|
|
|
|
|
|
config();
|
|
|
|
|
|
import { Prisma } from "@prisma/client";
|
|
|
import { writeAlert } from "@/modules/alert/service";
|
|
|
import {
|
|
|
QUOTE_TIMEOUT_MS,
|
|
|
SWEEPER_INTERVAL_MS,
|
|
|
} from "@/lib/constants/quote";
|
|
|
import { prisma } from "@/lib/prisma";
|
|
|
import { getL3 } from "@/modules/cache/redis-cache";
|
|
|
import { getConfidenceScore } from "@/modules/quote/confidence";
|
|
|
import {
|
|
|
applyMarkupToQuotes,
|
|
|
getMarkupRule,
|
|
|
type RawQuoteTier,
|
|
|
} from "@/modules/pricing/engine";
|
|
|
import { prepareMotherShipStorageQuotes } from "@/modules/quote/quote-completeness";
|
|
|
import { recordPostDone, safeRecord } from "@/modules/metrics/collector";
|
|
|
import { formatQuoteErrorMessage } from "@/modules/quote/quote-error-messages";
|
|
|
import { recordQuoteQueryOutcome } from "@/modules/quote/query-log";
|
|
|
import type { L2CachePayload } from "@/modules/quote/types";
|
|
|
import { getQuoteRpaQueue } from "@/workers/rpa/queue";
|
|
|
|
|
|
const TIMEOUT_ERROR = `processing 超过 ${Math.round(QUOTE_TIMEOUT_MS / 1_000)} 秒未完成`;
|
|
|
|
|
|
async function writeTimeoutAlert(
|
|
|
quoteId: string,
|
|
|
cargoHash: string,
|
|
|
alertType: "STALE_FALLBACK" | "RPA_FAILED",
|
|
|
detail: Record<string, unknown>,
|
|
|
): Promise<void> {
|
|
|
await writeAlert(alertType, { quoteId, cargoHash, detail });
|
|
|
}
|
|
|
|
|
|
/** 单条 processing 超时记录处理:优先 L3 降级,否则 QUOTE_TIMEOUT */
|
|
|
export async function sweepTimedOutQuote(quoteId: string): Promise<void> {
|
|
|
const record = await prisma.quoteRecord.findFirst({
|
|
|
where: { quoteId, status: "processing", isDeleted: false },
|
|
|
});
|
|
|
if (!record) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const elapsed = Date.now() - record.createdAt.getTime();
|
|
|
if (elapsed < QUOTE_TIMEOUT_MS) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const l3 = await getL3<L2CachePayload>(record.cargoHash);
|
|
|
|
|
|
if (l3?.quotes?.length) {
|
|
|
let uiQuotes: RawQuoteTier[];
|
|
|
try {
|
|
|
uiQuotes = prepareMotherShipStorageQuotes(l3.quotes as RawQuoteTier[]);
|
|
|
} catch {
|
|
|
await markQuoteTimeout(record.quoteId, record.cargoHash);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const markupRule = await getMarkupRule(record.customerId);
|
|
|
const markedQuotes = applyMarkupToQuotes(uiQuotes, markupRule);
|
|
|
const validUntil = new Date(Date.now() + 3 * 60 * 1_000);
|
|
|
|
|
|
await prisma.quoteRecord.update({
|
|
|
where: { quoteId: record.quoteId },
|
|
|
data: {
|
|
|
status: "done",
|
|
|
sourceType: "stale",
|
|
|
isRealtime: false,
|
|
|
confidenceScore: getConfidenceScore("stale"),
|
|
|
markupPercent:
|
|
|
markupRule.type === "percent" ? markupRule.percent : 0,
|
|
|
quotesJson: markedQuotes as unknown as Prisma.InputJsonValue,
|
|
|
validUntil,
|
|
|
errorCode: null,
|
|
|
},
|
|
|
});
|
|
|
|
|
|
await writeTimeoutAlert(record.quoteId, record.cargoHash, "STALE_FALLBACK", {
|
|
|
reason: TIMEOUT_ERROR,
|
|
|
customer_id: record.customerId,
|
|
|
});
|
|
|
await recordQuoteQueryOutcome(record.quoteId, "stale", {
|
|
|
failureReason: TIMEOUT_ERROR,
|
|
|
sourceType: "stale",
|
|
|
tierCount: markedQuotes.length,
|
|
|
});
|
|
|
safeRecord(() =>
|
|
|
recordPostDone({
|
|
|
quoteId: record.quoteId,
|
|
|
sourceType: "stale",
|
|
|
isRealtime: false,
|
|
|
}),
|
|
|
);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
await markQuoteTimeout(record.quoteId, record.cargoHash);
|
|
|
}
|
|
|
|
|
|
async function cancelQuoteRpaJob(quoteId: string): Promise<void> {
|
|
|
try {
|
|
|
const job = await getQuoteRpaQueue().getJob(quoteId);
|
|
|
if (!job) {
|
|
|
return;
|
|
|
}
|
|
|
const state = await job.getState();
|
|
|
if (state === "active") {
|
|
|
await job.moveToFailed(new Error("QUOTE_TIMEOUT sweeper"), "0", true);
|
|
|
} else if (state === "waiting" || state === "delayed") {
|
|
|
await job.remove();
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.warn(
|
|
|
`[timeout-sweeper] 清理队列 job 失败 quote_id=${quoteId}:`,
|
|
|
error instanceof Error ? error.message : error,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function markQuoteTimeout(
|
|
|
quoteId: string,
|
|
|
cargoHash: string,
|
|
|
): Promise<void> {
|
|
|
await cancelQuoteRpaJob(quoteId);
|
|
|
await prisma.quoteRecord.update({
|
|
|
where: { quoteId },
|
|
|
data: {
|
|
|
status: "failed",
|
|
|
errorCode: "QUOTE_TIMEOUT",
|
|
|
errorMessage: formatQuoteErrorMessage("QUOTE_TIMEOUT"),
|
|
|
isRealtime: false,
|
|
|
},
|
|
|
});
|
|
|
await writeTimeoutAlert(quoteId, cargoHash, "RPA_FAILED", {
|
|
|
reason: TIMEOUT_ERROR,
|
|
|
});
|
|
|
await recordQuoteQueryOutcome(quoteId, "failed", {
|
|
|
failureReason: formatQuoteErrorMessage("QUOTE_TIMEOUT"),
|
|
|
errorCode: "QUOTE_TIMEOUT",
|
|
|
});
|
|
|
}
|
|
|
|
|
|
/** 扫描所有超时 processing 记录 */
|
|
|
export async function sweepTimedOutQuotes(): Promise<number> {
|
|
|
const cutoff = new Date(Date.now() - QUOTE_TIMEOUT_MS);
|
|
|
const staleRecords = await prisma.quoteRecord.findMany({
|
|
|
where: {
|
|
|
status: "processing",
|
|
|
isDeleted: false,
|
|
|
createdAt: { lt: cutoff },
|
|
|
},
|
|
|
select: { quoteId: true },
|
|
|
take: 50,
|
|
|
});
|
|
|
|
|
|
if (staleRecords.length === 0) {
|
|
|
console.log(
|
|
|
`[timeout-sweeper] 扫描完成,无超时记录(阈值 ${QUOTE_TIMEOUT_MS / 1000}s)`,
|
|
|
);
|
|
|
return 0;
|
|
|
}
|
|
|
|
|
|
console.log(
|
|
|
`[timeout-sweeper] 发现 ${staleRecords.length} 条超时 processing,开始处理...`,
|
|
|
);
|
|
|
|
|
|
for (const { quoteId } of staleRecords) {
|
|
|
await sweepTimedOutQuote(quoteId);
|
|
|
console.log(`[timeout-sweeper] 已处理 quote_id=${quoteId}`);
|
|
|
}
|
|
|
|
|
|
return staleRecords.length;
|
|
|
}
|
|
|
|
|
|
/** 启动定时扫描(每 10s),启动时立即执行首轮 */
|
|
|
export function startTimeoutSweeper(): NodeJS.Timeout {
|
|
|
sweepTimedOutQuotes().catch((error) => {
|
|
|
console.error("[timeout-sweeper] 首轮扫描异常:", error);
|
|
|
});
|
|
|
|
|
|
const timer = setInterval(() => {
|
|
|
sweepTimedOutQuotes().catch((error) => {
|
|
|
console.error("[timeout-sweeper] 扫描异常:", error);
|
|
|
});
|
|
|
}, SWEEPER_INTERVAL_MS);
|
|
|
|
|
|
return timer;
|
|
|
}
|
|
|
|
|
|
async function runOnce(): Promise<void> {
|
|
|
console.log("[timeout-sweeper] 单次扫描模式");
|
|
|
const count = await sweepTimedOutQuotes();
|
|
|
console.log(`[timeout-sweeper] 单次扫描结束,处理 ${count} 条`);
|
|
|
await prisma.$disconnect();
|
|
|
}
|
|
|
|
|
|
if (require.main === module) {
|
|
|
const once = process.argv.includes("--once");
|
|
|
|
|
|
if (!process.env.DATABASE_URL) {
|
|
|
console.error("[timeout-sweeper] DATABASE_URL 未配置,请检查 .env");
|
|
|
process.exit(1);
|
|
|
}
|
|
|
|
|
|
console.log("[timeout-sweeper] 启动,间隔", SWEEPER_INTERVAL_MS, "ms");
|
|
|
|
|
|
if (once) {
|
|
|
runOnce()
|
|
|
.catch((error) => {
|
|
|
console.error("[timeout-sweeper] 异常:", error);
|
|
|
process.exit(1);
|
|
|
})
|
|
|
.finally(() => process.exit(0));
|
|
|
} else {
|
|
|
startTimeoutSweeper();
|
|
|
}
|
|
|
}
|