|
|
/**
|
|
|
* Flock Freight get-a-quote RPA(PRD §14 / 技术设计 §7.6)
|
|
|
* Direct:POST api.flockfreight.com/user/quotes;失败可回退 DOM 填表
|
|
|
* 客户账密:强制 DOM 登录,跳过 Direct
|
|
|
*/
|
|
|
import {
|
|
|
formatExternalSiteNavigationError,
|
|
|
isTransientNavigationError,
|
|
|
} from "@/lib/rpa/page-goto";
|
|
|
import { fetchFlockQuotesViaDirect } from "@/lib/flock/direct-quote";
|
|
|
import {
|
|
|
getFlockLoginCredentials,
|
|
|
getFlockQuoteMode,
|
|
|
getFlockSessionMaxRotations,
|
|
|
isFlockDirectQuoteEnabled,
|
|
|
isFlockDomQuoteFallbackEnabled,
|
|
|
isFlockWorkerReuseSession,
|
|
|
} from "@/lib/flock/env";
|
|
|
import {
|
|
|
getFlockLoginSource,
|
|
|
hasEffectiveFlockLogin,
|
|
|
runWithFlockLoginContext,
|
|
|
} from "@/lib/flock/login-context";
|
|
|
import { withFlockExclusiveLock } from "@/lib/flock/exclusive-lock";
|
|
|
import { getCustomerProviderLogin, hasCustomerProviderCredential } from "@/modules/customer/provider-credentials";
|
|
|
import { runFlockVisualChain } from "@/workers/rpa/flock/visual-chain";
|
|
|
import {
|
|
|
runFlockQuoteWithWorkerSession,
|
|
|
shouldRotateFlockSession,
|
|
|
} from "@/workers/rpa/flock/worker-session";
|
|
|
import type { FlockStageReporter } from "@/lib/flock/rpa-progress";
|
|
|
import { reportFlockStage } from "@/lib/flock/rpa-progress";
|
|
|
import type {
|
|
|
FlockQuoteInput,
|
|
|
FlockQuoteLine,
|
|
|
FlockRunQuoteResult,
|
|
|
} from "@/workers/rpa/flock/types";
|
|
|
|
|
|
export type { FlockStageReporter };
|
|
|
|
|
|
function isFlockQuoteLimitError(errorMessage?: string): boolean {
|
|
|
return /flock_quote_limit|thank you for your interest/i.test(
|
|
|
errorMessage ?? "",
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** 无痕全链:拒号/撞号时换邮箱重试(每轮新浏览器,对齐 batch 有效路径) */
|
|
|
export async function runFlockVisualChainWithRotations(
|
|
|
input: FlockQuoteInput,
|
|
|
onStage?: FlockStageReporter,
|
|
|
): Promise<FlockRunQuoteResult> {
|
|
|
const maxRotations = getFlockSessionMaxRotations();
|
|
|
let lastResult: FlockRunQuoteResult = {
|
|
|
ok: false,
|
|
|
quotes: [],
|
|
|
errorMessage: "FLOCK_QUOTE_LIMIT:未执行查价",
|
|
|
};
|
|
|
|
|
|
for (let rotation = 0; rotation < maxRotations; rotation += 1) {
|
|
|
const result = await runFlockVisualChain({ input, onStage });
|
|
|
lastResult = result;
|
|
|
|
|
|
if (result.ok) {
|
|
|
return result;
|
|
|
}
|
|
|
if (isFlockQuoteLimitError(result.errorMessage)) {
|
|
|
return result;
|
|
|
}
|
|
|
if (shouldRotateFlockSession(result.errorMessage)) {
|
|
|
console.warn(
|
|
|
`[flock-rpa] ${result.errorMessage?.slice(0, 80)}… 无痕换新邮箱 (${rotation + 1}/${maxRotations})`,
|
|
|
);
|
|
|
continue;
|
|
|
}
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
ok: false,
|
|
|
quotes: [],
|
|
|
errorMessage:
|
|
|
lastResult.errorMessage ??
|
|
|
`FLOCK_ACCOUNT_REJECTED:已换 ${maxRotations} 个邮箱仍无法查价`,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function mockFlockQuotes(): FlockQuoteLine[] {
|
|
|
return [
|
|
|
{
|
|
|
tier: "flock_direct",
|
|
|
serviceLevel: "guaranteed",
|
|
|
rateOption: "fastest",
|
|
|
carrier: "Flock Freight",
|
|
|
label: "FlockDirect®",
|
|
|
totalUsd: 2330,
|
|
|
transitDays: "4",
|
|
|
transitDescription: "4 天(含周末)",
|
|
|
reference: "FRG-MOCK",
|
|
|
},
|
|
|
{
|
|
|
tier: "standard",
|
|
|
serviceLevel: "standard",
|
|
|
rateOption: "lowest",
|
|
|
carrier: "Flock Freight",
|
|
|
label: "Standard",
|
|
|
totalUsd: 769,
|
|
|
transitDays: "3-4",
|
|
|
transitDescription: "3–4 个工作日(预估)",
|
|
|
reference: "FRG-MOCK",
|
|
|
},
|
|
|
];
|
|
|
}
|
|
|
|
|
|
async function runFlockDomPath(
|
|
|
input: FlockQuoteInput,
|
|
|
onStage?: FlockStageReporter,
|
|
|
): Promise<FlockRunQuoteResult> {
|
|
|
if (hasEffectiveFlockLogin()) {
|
|
|
console.log(
|
|
|
`[flock-rpa] DOM 使用登录凭据 source=${getFlockLoginSource()},跳过邮箱轮换`,
|
|
|
);
|
|
|
if (isFlockWorkerReuseSession()) {
|
|
|
return runFlockQuoteWithWorkerSession(input, onStage);
|
|
|
}
|
|
|
return runFlockVisualChain({ input, onStage });
|
|
|
}
|
|
|
if (isFlockWorkerReuseSession()) {
|
|
|
return runFlockQuoteWithWorkerSession(input, onStage);
|
|
|
}
|
|
|
return runFlockVisualChainWithRotations(input, onStage);
|
|
|
}
|
|
|
|
|
|
export type RunFlockQuoteOptions = {
|
|
|
customerId?: string;
|
|
|
onStage?: FlockStageReporter;
|
|
|
};
|
|
|
|
|
|
/** 执行 Flock 官网查价(Mock / Direct / DOM) */
|
|
|
export async function runFlockQuoteRpa(
|
|
|
input: FlockQuoteInput,
|
|
|
options?: RunFlockQuoteOptions,
|
|
|
): Promise<FlockRunQuoteResult> {
|
|
|
if (process.env.RPA_MOCK_MODE === "true") {
|
|
|
return { ok: true, quotes: mockFlockQuotes(), reference: "FRG-MOCK" };
|
|
|
}
|
|
|
|
|
|
const customerId = options?.customerId?.trim();
|
|
|
let customerLogin = customerId
|
|
|
? await getCustomerProviderLogin(customerId, "flock")
|
|
|
: null;
|
|
|
|
|
|
const onStage = options?.onStage;
|
|
|
|
|
|
// 已配置账密但解密失败:禁止回落 Direct API(否则拿不到折扣价)
|
|
|
if (customerId && !customerLogin) {
|
|
|
const configured = await hasCustomerProviderCredential(customerId, "flock");
|
|
|
if (configured) {
|
|
|
await reportFlockStage(onStage, "login");
|
|
|
return {
|
|
|
ok: false,
|
|
|
quotes: [],
|
|
|
errorMessage:
|
|
|
"FLOCK_LOGIN_FAILED:客户已配置 Flock 账密但解密失败,请检查 JWT_SECRET 后重新保存账密(禁止改走 Direct API)",
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const envLogin = getFlockLoginCredentials();
|
|
|
const login = customerLogin ?? envLogin;
|
|
|
const source = customerLogin ? "customer" : envLogin ? "env" : null;
|
|
|
|
|
|
try {
|
|
|
return await withFlockExclusiveLock(
|
|
|
"embed-worker",
|
|
|
() =>
|
|
|
runWithFlockLoginContext(login, source, async () => {
|
|
|
const mode = getFlockQuoteMode();
|
|
|
console.log(
|
|
|
`[flock-rpa] quoteMode=${mode} hasLogin=${Boolean(login)} source=${source ?? "none"} path=${login ? "dom-login" : "direct_or_dom"}`,
|
|
|
);
|
|
|
|
|
|
// 客户/环境账密:强制官网登录 DOM,禁止匿名 Direct API
|
|
|
if (login) {
|
|
|
console.log(
|
|
|
`[flock-rpa] 已配置登录账密,强制 DOM 登录查价(禁止 Direct /user/quotes)source=${source}`,
|
|
|
);
|
|
|
return runFlockDomPath(input, onStage);
|
|
|
}
|
|
|
|
|
|
if (isFlockDirectQuoteEnabled()) {
|
|
|
const direct = await fetchFlockQuotesViaDirect(input);
|
|
|
if (direct.ok) {
|
|
|
await reportFlockStage(onStage, "done");
|
|
|
return direct;
|
|
|
}
|
|
|
console.warn(
|
|
|
`[flock-rpa] Direct 失败: ${direct.errorMessage?.slice(0, 160) ?? "unknown"}`,
|
|
|
);
|
|
|
if (!isFlockDomQuoteFallbackEnabled()) {
|
|
|
return direct;
|
|
|
}
|
|
|
console.warn("[flock-rpa] 回退 DOM 填表路径…");
|
|
|
}
|
|
|
|
|
|
return runFlockDomPath(input, onStage);
|
|
|
}),
|
|
|
{ waitMs: 30_000, pollMs: 500 },
|
|
|
);
|
|
|
} catch (error) {
|
|
|
const raw = error instanceof Error ? error.message : String(error);
|
|
|
return {
|
|
|
ok: false,
|
|
|
quotes: [],
|
|
|
errorMessage: isTransientNavigationError({ message: raw })
|
|
|
? formatExternalSiteNavigationError("Flock Freight 官网", {
|
|
|
message: raw,
|
|
|
})
|
|
|
: raw,
|
|
|
};
|
|
|
}
|
|
|
}
|