import { QUOTE_TIMEOUT_MS } from "@/lib/constants/quote"; import { MS_NEEDS_DETAILS_MESSAGE } from "@/lib/constants/ms-refine-hold"; 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"; import { scrubProviderBrandForCustomer } from "@/lib/frontend/scrub-provider-brand"; 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 / 官网控件 testId → 中文名(须具体到控件,禁止笼统「创建货件/报价表单控件」) * 顺序:更具体的规则在前。 */ const TEST_ID_ZH: Array<[RegExp, string]> = [ [ /address-book-accessorials-option-appointment/i, "预约送货(Appointment)附加服务选项", ], [/address-book-accessorials-option-/i, "地址簿附加服务选项"], [/address-book-accessorials-trigger/i, "附加服务下拉入口"], [/auth-email-input|auth-password-input|auth-log-in-button/i, "登录表单"], [/quote-create-pickup-input-search/i, "提货地址搜索框"], [/quote-create-delivery-input-search/i, "派送地址搜索框"], [/ship-create-continue-button/i, "创建货件页「继续」按钮"], [/cargo-type-dropdown-input/i, "货物类型下拉框"], [/rate-card/i, "承运商报价卡片"], [/quote-details-rate-option-/i, "报价选项卡片"], [/quote-create-/i, "创建货件页表单字段"], [/ship-create-/i, "创建货件页操作按钮"], ]; /** 按控件给出可核对的超时原因(禁止臆测「附加服务未展开」等无关套话) */ const TARGET_TIMEOUT_HINT: Record = { 提货地址搜索框: { wait: "创建货件页未渲染出提货地址框,或登录会话未进入创建页", act: "提货地址框被 Inbox/弹窗遮挡,或页面仍在加载", }, 派送地址搜索框: { wait: "创建货件页未渲染出派送地址框,或登录会话未进入创建页", act: "派送地址框被 Inbox/弹窗遮挡,或页面仍在加载", }, "创建货件页「继续」按钮": { wait: "创建货件页「继续」按钮未出现(地址/货物可能未确认,或仍停在登录/首页)", act: "「继续」按钮仍禁用或被遮罩挡住,无法提交一级询价", }, 承运商报价卡片: { wait: "官网未返回可用报价卡片(仍在算价、线路无运力、或已要求补填二级详情)", act: "报价卡片存在但无法点选(可能被 Inbox/遮罩挡住)", }, 货物类型下拉框: { wait: "货物类型下拉未出现(页面未滚到货物区,或表单结构变化)", act: "货物类型下拉被遮挡或暂时不可点", }, 附加服务下拉入口: { wait: "附加服务入口未出现(地址尚未确认)", act: "附加服务入口被遮挡或暂时不可点", }, "预约送货(Appointment)附加服务选项": { wait: "预约送货选项未出现在附加服务列表中(当前地址可能不支持)", act: "预约送货选项不可点或未展开", }, 地址簿附加服务选项: { wait: "所需附加服务选项未出现在列表中", act: "附加服务选项不可点或未展开", }, 登录表单: { wait: "未进入官网登录页或登录表单未加载", act: "登录表单填写/提交失败", }, }; /** 常见英文业务/官网文案 → 中文 */ 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, "请检查并填写所有必填项后再继续", ], [ /MS_NEEDS_DETAILS|一级询价未能直接出价|请继续填写二级详情/i, MS_NEEDS_DETAILS_MESSAGE, ], [ /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; } function extractTimeoutSeconds(text: string): number | null { const timeoutMatch = text.match(/Timeout\s+(\d+)\s*ms/i); if (!timeoutMatch) return null; const ms = Number(timeoutMatch[1]); if (!Number.isFinite(ms) || ms <= 0) return null; return Math.max(1, Math.round(ms / 1000)); } /** click/fill 与 waitFor(visible) 文案不同 */ function isActionTimeout(text: string): boolean { return /locator\.(?:click|fill|type|press)|waiting for (?:locator\.)?(?:click|fill)/i.test( text, ); } function formatControlTimeoutMessage( target: string, text: string, ): string { const sec = extractTimeoutSeconds(text); const waitHint = sec != null ? `(等待约 ${sec} 秒)` : ""; const action = isActionTimeout(text); const verb = action ? "操作" : "等到"; const hintEntry = TARGET_TIMEOUT_HINT[target]; const reason = hintEntry ? action ? hintEntry.act : hintEntry.wait : action ? "目标控件被遮挡、仍禁用,或页面未加载完成" : "目标控件未出现或未变为可见"; return `自动化操作超时${waitHint}:未能${verb}「${target}」。原因:${reason}。请重试;若反复失败请联系管理员查看 Worker 日志。`; } /** * 纠正历史入库的笼统超时文案(已丢失原始 testId,只能按时间特征收紧表述) */ export function rewriteLegacyVagueTimeoutMessage(raw: string): string | null { const text = raw.trim(); if (!/自动化操作超时/i.test(text)) return null; if ( /未能等到「创建货件\/报价表单控件」|未能等到「创建货件页表单字段」|可能原因:官网页面结构变更、附加服务选项未展开/i.test( text, ) ) { const secMatch = text.match(/等待约\s*(\d+)\s*秒/); const sec = secMatch ? Number(secMatch[1]) : null; const waitHint = sec != null ? `(等待约 ${sec} 秒)` : ""; if (sec != null && sec <= 8) { return `自动化操作超时${waitHint}:未能及时操作创建货件页控件(多为提货/派送地址搜索框或「继续」按钮被 Inbox/弹窗遮挡,或页面未加载完)。请重试;若反复失败请联系管理员查看 Worker 日志。`; } return `自动化操作超时${waitHint}:创建货件页关键控件未按时出现(可能未进入创建页、登录会话异常,或官网仍在加载)。请重试;若反复失败请联系管理员查看 Worker 日志。`; } return null; } /** 可提货日/日历失败:去掉 want=/sample= 英文诊断,统一中文 */ export function sanitizeCalendarPickErrorMessage(raw: string): string | null { const text = raw.trim(); if ( !/登录态日历未点到|未能选择可提货日|未能切换到可提货月|可提货日无效/i.test( text, ) ) { return null; } if (/可提货日无效/i.test(text)) { const bad = text.match(/20\d{2}-\d{2}-\d{2}/); return bad ? `可提货日无效:${bad[0]}` : "可提货日无效,请更换日期后重试"; } if (/未能切换到可提货月/i.test(text)) { const ym = text.match(/(\d{4})年(\d{1,2})月/); return ym ? `未能切换到可提货月 ${ym[1]}年${ym[2]}月,请稍后重试或更换日期` : "未能切换到可提货月,请稍后重试或更换日期"; } const dateMatch = text.match(/20\d{2}-\d{2}-\d{2}/); return dateMatch ? `未能选择可提货日 ${dateMatch[0]},请稍后重试或更换日期` : "未能选择可提货日,请稍后重试或更换日期"; } /** * Playwright / 堆栈 / 已知错误码 → 中文根因(客户与管理端共用)。 * 无法识别时返回空串(由调用方按 error_code 回退)。 */ export function normalizeTechnicalReason(raw: string): string { const text = raw.trim(); if (!text) { return ""; } const legacyTimeout = rewriteLegacyVagueTimeoutMessage(text); if (legacyTimeout) { return legacyTimeout; } const calendarMsg = sanitizeCalendarPickErrorMessage(text); if (calendarMsg) { return calendarMsg; } 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 "暂时无法获取报价,请稍后重试"; } if ( /PROVIDER_LOGIN_FAILED|FLOCK_LOGIN_FAILED|仍停在登录页|账号或密码错误|账密可能错误/i.test( text, ) ) { return "承运商官网登录失败:账号或密码不正确,或修改后未在本系统即时保存更新"; } if (/SESSION_EXPIRED|storageState|会话已过期|登录会话失效/i.test(text)) { return "报价会话已失效,请重新询价"; } if (/缺少标准档报价/i.test(text)) { return "报价结果缺少标准档,无法展示完整运价"; } if (/官网展示档位为空|报价档位数不足/i.test(text)) { return "未能解析出可用承运商报价档位"; } if (/RPA_DATA_INVALID|无法解析.*报价|未能解析出承运商报价/i.test(text)) { return "返回的报价数据无法解析为完整档位"; } if (/ADDRESS_NOT_CONFIRMED|地址.*无法定位|地址未确认/i.test(text)) { return "该地址无法确认,请重新选择后询价"; } if (/二级填写已超时|确认窗口已超时|超过 5 分钟/i.test(text)) { return MS_NEEDS_DETAILS_MESSAGE; } 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) ) { if ( /First name|Last name|contact-first|contact-last|element is not enabled/i.test( text, ) ) { return "自动化操作超时:官网 Details 联系人字段未解锁(禁用态)。原因:Inbox 遮挡、附加服务推荐未点 Skip、或需先点 Save & update quote。"; } if ( /ready.?pickup|Ready for pick|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec.*20\d{2}|Chevron/i.test( text, ) ) { const sec = extractTimeoutSeconds(text); const waitHint = sec != null ? `(等待约 ${sec} 秒)` : ""; return `自动化操作超时${waitHint}:未能打开或选择「可提货日」。原因:Inbox 消息遮挡、日期控件改版,或网络过慢。`; } const target = describeTestIdTarget(text); if (target) { return formatControlTimeoutMessage(target, text); } const sec = extractTimeoutSeconds(text); const waitHint = sec != null ? `(等待约 ${sec} 秒)` : ""; 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() ?? ""; let out = fallback; if (!raw) { out = fallback; } else if ( code === "PROVIDER_LOGIN_FAILED" || isProviderLoginFailureMessage(raw) ) { out = PROVIDER_LOGIN_FAILED_USER_MESSAGE; } else { const stripped = stripErrorCodePrefix(raw); const legacyTimeout = rewriteLegacyVagueTimeoutMessage(stripped || raw); if (legacyTimeout) { out = legacyTimeout; } else { const calendarClean = sanitizeCalendarPickErrorMessage(stripped) || sanitizeCalendarPickErrorMessage(raw); if (calendarClean) { out = calendarClean; } else if (isChineseUserFacingMessage(stripped)) { out = stripped; } else if (isChineseUserFacingMessage(raw)) { out = raw; } else { const translated = translateKnownEnglishPhrases(stripped || raw); if (translated !== (stripped || raw) && hasCjk(translated)) { out = translated; } else { const fromTech = normalizeTechnicalReason(stripped || raw); out = fromTech || fallback; } } } } return scrubProviderBrandForCustomer(out); } 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)}…`; }