You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

355 lines
13 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { QUOTE_TIMEOUT_MS } from "@/lib/constants/quote";
import { RpaDataInvalidError } from "@/modules/quote/types";
import type { RpaErrorCode } from "@/modules/rpa/errors";
import { RpaError } from "@/modules/rpa/errors";
import {
isProviderLoginFailureMessage,
PROVIDER_LOGIN_FAILED_USER_MESSAGE,
} from "@/modules/rpa/provider-login-message";
const QUOTE_TIMEOUT_SEC = Math.round(QUOTE_TIMEOUT_MS / 1_000);
/** 对外展示的询价失败 error_codedomain-rules §10 + RPA 细分) */
export type QuoteFailureCode =
| "QUOTE_UNAVAILABLE"
| "QUOTE_TIMEOUT"
| "QUOTE_ENTRY_UNAVAILABLE"
| "ADDRESS_SUGGESTION_NOT_FOUND"
| "CARRIER_NO_CAPACITY"
| "ADDRESS_NOT_SUPPORTED"
| "RPA_CAPTCHA"
| "SESSION_EXPIRED"
| "PROVIDER_LOGIN_FAILED"
| "RPA_DATA_INVALID"
| "PAGE_LOAD_TIMEOUT";
/** 面向中文客户的默认文案(禁止英文技术栈细节) */
const DEFAULT_MESSAGES: Record<QuoteFailureCode, string> = {
QUOTE_UNAVAILABLE: "暂时无法获取报价,请稍后重试",
QUOTE_TIMEOUT: `查询超时(超过 ${QUOTE_TIMEOUT_SEC} 秒),请重试`,
QUOTE_ENTRY_UNAVAILABLE: "报价入口暂时无法打开,请稍后重试或联系管理员",
ADDRESS_SUGGESTION_NOT_FOUND:
"无法匹配您已确认的地址,请重新选择提货/派送地址",
CARRIER_NO_CAPACITY: "该线路暂无可用运力,请调整货物或地址后重试",
ADDRESS_NOT_SUPPORTED: "该地址不在承运商服务范围内",
RPA_CAPTCHA: "承运商触发验证码,询价已暂停,请稍后重试或联系管理员",
SESSION_EXPIRED: "承运商登录会话已过期,请确认账密后重试",
PROVIDER_LOGIN_FAILED: PROVIDER_LOGIN_FAILED_USER_MESSAGE,
RPA_DATA_INVALID: "未能解析完整报价结果,请稍后重试",
PAGE_LOAD_TIMEOUT: "承运商页面加载超时,请稍后重试",
};
const RPA_CODE_MAP: Partial<Record<RpaErrorCode, QuoteFailureCode>> = {
QUOTE_ENTRY_UNAVAILABLE: "QUOTE_ENTRY_UNAVAILABLE",
ADDRESS_SUGGESTION_NOT_FOUND: "ADDRESS_SUGGESTION_NOT_FOUND",
CARRIER_NO_CAPACITY: "CARRIER_NO_CAPACITY",
ADDRESS_NOT_SUPPORTED: "ADDRESS_NOT_SUPPORTED",
RPA_CAPTCHA: "RPA_CAPTCHA",
SESSION_EXPIRED: "SESSION_EXPIRED",
PROVIDER_LOGIN_FAILED: "PROVIDER_LOGIN_FAILED",
RPA_DATA_INVALID: "RPA_DATA_INVALID",
PAGE_LOAD_TIMEOUT: "PAGE_LOAD_TIMEOUT",
STRUCT_CHANGE: "QUOTE_ENTRY_UNAVAILABLE",
};
const CJK_PATTERN = /[\u4e00-\u9fff]/;
/** 仅匹配明确的 Playwright/堆栈噪声;勿把「含少量英文的中文业务句」整段判死刑 */
const TECHNICAL_NOISE_PATTERN =
/locator\.|page\.goto|Timeout \d+\s*ms|waitFor|getByTestId|Call log|ERR_|ECONN|Prisma|webpack|playwright|browserType|net::|stack|at Object\.|node_modules/i;
const ERROR_CODE_PREFIX =
/^(?:RPA_[A-Z0-9_]+|FLOCK_[A-Z0-9_]+|QUOTE_[A-Z0-9_]+|STRUCT_CHANGE|SESSION_EXPIRED|PAGE_LOAD_TIMEOUT|PROVIDER_LOGIN_FAILED|MOTHERSHIP_[A-Z0-9_]+|CARRIER_[A-Z0-9_]+|ADDRESS_[A-Z0-9_]+|CARGO_[A-Z0-9_]+)\s*[:]\s*/i;
/** 常见 Playwright / MotherShip 控件 testId → 中文含义 */
const TEST_ID_ZH: Array<[RegExp, string]> = [
[
/address-book-accessorials-option-appointment/i,
"预约送货Appointment附加服务选项",
],
[/address-book-accessorials-option-/i, "地址簿附加服务选项"],
[/auth-email-input|auth-password-input|auth-log-in-button/i, "登录表单"],
[/quote-create-pickup-input-search/i, "提货地址搜索框"],
[/quote-create-delivery-input-search/i, "派送地址搜索框"],
[/quote-create-|rate-card|ship-create/i, "创建货件/报价表单控件"],
];
/** 常见英文业务/官网文案 → 中文 */
const EN_PHRASE_TO_ZH: Array<[RegExp, string]> = [
[
/no rates found[\s\S]*please review all required fields to proceed/i,
"未找到可用报价。请检查并填写所有必填项后再继续",
],
[/no rates found/i, "未找到可用报价"],
[
/please review all required fields to proceed/i,
"请检查并填写所有必填项后再继续",
],
[
/temporarily unable to (?:obtain|get|fetch) quotes?/i,
"暂时无法获取报价,请检查地址、货物或附加服务后重试",
],
[/unable to (?:obtain|get|fetch) quotes?/i, "暂时无法获取报价"],
[/no carriers available/i, "该线路暂无可用运力"],
[/no capacity/i, "该线路暂无可用运力"],
[/unable to find a carrier/i, "未找到可用承运商"],
[/address not supported/i, "该地址不在服务范围内"],
[/zip code is not supported/i, "该邮编不在服务范围内"],
[/we don'?t service this area/i, "该区域暂不提供服务"],
[/unsupported accessorial/i, "不支持该附加服务"],
[
/our carrier partners? do not provide[^\n.]*/i,
"承运合作伙伴不提供此项服务",
],
[
/carrier partners? (?:don'?t|do not) provide[^\n.]*/i,
"承运合作伙伴不提供此项服务",
],
[
/(?:pickup|pick-up)[^\n.]{0,40}residential|residential[^\n.]{0,40}(?:pickup|pick-up)/i,
"不支持住宅地址上门取件",
],
[
/incorrect (?:email|password)|invalid (?:email|password|credentials)/i,
"账号或密码不正确",
],
[/wrong password/i, "密码不正确"],
[/captcha|challenge/i, "触发人机验证,请稍后重试"],
];
function hasCjk(text: string): boolean {
return CJK_PATTERN.test(text);
}
function countCjk(text: string): number {
return (text.match(/[\u4e00-\u9fff]/g) ?? []).length;
}
/** 去掉 RPA_DATA_INVALID 一类前缀,便于保留后面的中文原因 */
export function stripErrorCodePrefix(message: string): string {
return message.replace(ERROR_CODE_PREFIX, "").trim();
}
function describeTestIdTarget(text: string): string | null {
for (const [pattern, label] of TEST_ID_ZH) {
if (pattern.test(text)) {
return label;
}
}
const m = text.match(/getByTestId\(\s*['"]([^'"]+)['"]\s*\)/i);
if (m?.[1]) {
return `页面控件「${m[1]}`;
}
if (/getBy(?:Text|Role|Label|Placeholder)\([^)]+\)/i.test(text)) {
return "目标页面元素";
}
return null;
}
/**
* Playwright / 堆栈 / 已知错误码 → 中文根因(客户与管理端共用)。
* 无法识别时返回空串(由调用方按 error_code 回退)。
*/
export function normalizeTechnicalReason(raw: string): string {
const text = raw.trim();
if (!text) {
return "";
}
if (/browserType\.launch/i.test(text)) {
return "RPA Worker 无法启动浏览器,请检查 Worker 资源、Chromium 依赖与进程日志";
}
if (/Executable doesn't exist|chromium_headless_shell/i.test(text)) {
return "服务器未安装 Playwright 浏览器,请在 Worker 环境中安装浏览器后重试";
}
if (/QUOTE_ENTRY_UNAVAILABLE|报价入口不可用|报价页面无法打开/i.test(text)) {
return "MotherShip 报价页面无法打开或被重定向到登录页";
}
if (
/PROVIDER_LOGIN_FAILED|FLOCK_LOGIN_FAILED|仍停在登录页|账号或密码错误|账密可能错误/i.test(
text,
)
) {
return "承运商官网登录失败:账号或密码不正确,或修改后未在本系统即时保存更新";
}
if (/SESSION_EXPIRED|storageState|会话已过期|登录会话失效/i.test(text)) {
return "MotherShip 登录会话失效,需重新登录或更新登录态文件";
}
if (/缺少标准档报价/i.test(text)) {
return "报价结果缺少标准档,无法展示完整运价";
}
if (/官网展示档位为空|报价档位数不足/i.test(text)) {
return "未能解析出可用承运商报价档位";
}
if (/RPA_DATA_INVALID|无法解析.*报价|未能解析出承运商报价/i.test(text)) {
return "MotherShip 返回的报价数据无法解析为完整档位";
}
if (/ADDRESS_NOT_CONFIRMED|地址.*无法定位|地址未确认/i.test(text)) {
return "已选地址在 MotherShip 无法定位或未确认,需重新联想并确认";
}
if (/processing 超过|QUOTE_TIMEOUT/i.test(text)) {
return `询价超时(超过 ${QUOTE_TIMEOUT_SEC} 秒未完成)`;
}
if (
/locator\.waitFor|waiting for getBy|Timeout \d+\s*ms exceeded/i.test(text)
) {
const target = describeTestIdTarget(text);
const timeoutMatch = text.match(/Timeout\s+(\d+)\s*ms/i);
const sec = timeoutMatch
? Math.round(Number(timeoutMatch[1]) / 1000)
: null;
const waitHint = sec != null ? `(等待约 ${sec} 秒)` : "";
if (target) {
return `自动化操作超时${waitHint}:未能等到「${target}」出现或变为可见。可能原因:官网页面结构变更、附加服务选项未展开、网络过慢或登录态异常。`;
}
return `自动化操作超时${waitHint}:未能等到目标页面元素出现或变为可见。可能原因:官网页面结构变更、网络过慢或登录态异常。`;
}
if (/page\.goto:|net::ERR_/i.test(text)) {
return "打开承运商官网失败(网络连接异常或被重置),请检查 Worker 出口网络后重试";
}
if (/Target (?:page|context|browser) has been closed/i.test(text)) {
return "浏览器会话已关闭,询价中断,请重试";
}
// 已是可读中文业务句:原样返回(管理端与客户展示共用)
if (isChineseUserFacingMessage(text)) {
return stripErrorCodePrefix(text);
}
return "";
}
/** 是否适合直接给中文客户看(含汉字且非 Playwright 堆栈噪声) */
export function isChineseUserFacingMessage(message: string): boolean {
const trimmed = stripErrorCodePrefix(message.trim());
if (!trimmed) return false;
if (!hasCjk(trimmed)) return false;
const cjk = countCjk(trimmed);
// 业务中文句常夹杂 rate-card / Continue汉字够多且无堆栈特征则放行
if (cjk >= 4 && !TECHNICAL_NOISE_PATTERN.test(trimmed)) {
return true;
}
if (TECHNICAL_NOISE_PATTERN.test(trimmed)) {
return false;
}
return true;
}
/** 将已知英文短语替换为中文;无法识别则返回原串 */
export function translateKnownEnglishPhrases(message: string): string {
const trimmed = message.trim();
if (!trimmed) return trimmed;
for (const [pattern, zh] of EN_PHRASE_TO_ZH) {
if (pattern.test(trimmed)) {
return zh;
}
}
return trimmed;
}
function defaultMessageFor(code: QuoteFailureCode): string {
return DEFAULT_MESSAGES[code] ?? DEFAULT_MESSAGES.QUOTE_UNAVAILABLE;
}
/**
* 将任意错误原文转为中文用户可见文案。
* 规则:合格中文 → 保留;已知英文/Playwright → 译中文;否则按 error_code 回退(禁止吞掉可译原因)。
*/
export function toChineseUserFacingMessage(
message: string | null | undefined,
errorCode?: string | null,
): string {
const code = (errorCode ?? "QUOTE_UNAVAILABLE") as QuoteFailureCode;
const fallback = defaultMessageFor(
code in DEFAULT_MESSAGES ? code : "QUOTE_UNAVAILABLE",
);
const raw = message?.trim() ?? "";
if (!raw) {
return fallback;
}
if (
code === "PROVIDER_LOGIN_FAILED" ||
isProviderLoginFailureMessage(raw)
) {
return PROVIDER_LOGIN_FAILED_USER_MESSAGE;
}
const stripped = stripErrorCodePrefix(raw);
if (isChineseUserFacingMessage(stripped)) {
return stripped;
}
if (isChineseUserFacingMessage(raw)) {
return raw;
}
const translated = translateKnownEnglishPhrases(stripped || raw);
if (translated !== (stripped || raw) && hasCjk(translated)) {
return translated;
}
const fromTech = normalizeTechnicalReason(stripped || raw);
if (fromTech) {
return fromTech;
}
return fallback;
}
export function resolveQuoteFailureCode(error: unknown): QuoteFailureCode {
if (error instanceof RpaError) {
if (
error.code === "PROVIDER_LOGIN_FAILED" ||
isProviderLoginFailureMessage(error.message)
) {
return "PROVIDER_LOGIN_FAILED";
}
return RPA_CODE_MAP[error.code] ?? "QUOTE_UNAVAILABLE";
}
if (error instanceof RpaDataInvalidError) {
return "RPA_DATA_INVALID";
}
if (error instanceof Error && isProviderLoginFailureMessage(error.message)) {
return "PROVIDER_LOGIN_FAILED";
}
if (
error instanceof Error &&
/Timeout \d+\s*ms|locator\.|page\.goto|net::ERR_/i.test(error.message)
) {
return "PAGE_LOAD_TIMEOUT";
}
return "QUOTE_UNAVAILABLE";
}
export function resolveQuoteFailureMessage(
error: unknown,
errorCode: QuoteFailureCode,
): string {
if (errorCode === "PROVIDER_LOGIN_FAILED") {
return PROVIDER_LOGIN_FAILED_USER_MESSAGE;
}
const raw =
error instanceof Error
? error.message.trim()
: typeof error === "string"
? error.trim()
: "";
return toChineseUserFacingMessage(raw, errorCode);
}
/** GET /quotes/{id} 展示文案:强制中文客户可读 */
export function formatQuoteErrorMessage(
errorCode: string | null | undefined,
storedMessage?: string | null,
): string {
return toChineseUserFacingMessage(storedMessage, errorCode);
}
/** quote_record.error_message VARCHAR(512) */
export function truncateQuoteErrorMessage(
message: string,
maxLen = 512,
): string {
const trimmed = message.trim();
if (trimmed.length <= maxLen) {
return trimmed;
}
return `${trimmed.slice(0, maxLen - 1)}`;
}