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_code(domain-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 = { 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> = { 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)}…`; }