#!/usr/bin/env tsx /** * 人工 docx 10 组 — 系统全链路探针(HTTP API,非 RPA 代码直调) * * 流程:候选 API → POST /api/quotes → 轮询 GET /api/quotes/:id → 校验 done + tiers * 地址来源:docs/美国跨州货物运输报价汇总_edited3.docx * * 前置:npm run dev:start(Next + RPA Worker + Redis + MySQL) * * PowerShell: * npm run probe:human-doc-system * $env:PROBE_HUMAN_ONLY="H01,H07"; npm run probe:human-doc-system */ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { loadDevEnv } from "@/lib/dev-env"; import { ensureNativeInfraIfNeeded } from "@/lib/infra/ensure-native-infra"; import { hostCreateQuote, hostFetchMothershipCandidates, hostGetQuote, } from "@/lib/frontend/api-client"; import { applyMothershipCandidate } from "@/lib/frontend/mothership-address"; import type { MothershipAddressCandidate, QuoteRequestBody } from "@/lib/frontend/types"; import { pollQuoteUntilDone } from "@/hooks/use-quote-polling"; import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health"; import { ensureRedisReady, resetRedisClient } from "@/lib/redis"; import { HUMAN_DOC_10_PAIRS, HUMAN_DOC_CARGO, type HumanDocPair, } from "./lib/human-doc-10pairs"; import { pickBestMothershipCandidate } from "./lib/pick-mothership-candidate"; loadDevEnv(); const BASE = process.env.PROBE_BASE_URL ?? "http://localhost:3000"; const TOKEN = "demo-host-token"; const CUSTOMER_ID = "CUST_001"; const POLL_TIMEOUT_MS = Number( process.env.PROBE_HUMAN_POLL_MS ?? process.env.QUOTE_TIMEOUT_MS ?? 600_000, ); const ONLY = (process.env.PROBE_HUMAN_ONLY ?? "") .split(",") .map((s) => s.trim().toUpperCase()) .filter(Boolean); function selectPairs(): HumanDocPair[] { if (!ONLY.length) { return HUMAN_DOC_10_PAIRS; } return HUMAN_DOC_10_PAIRS.filter((p) => ONLY.includes(p.id)); } function uniqueWeight(base: number): number { const nonce = (parseInt(randomUUID().replace(/-/g, ""), 16) % 900) + 1; return base + nonce; } async function waitForHttpOk(url: string, attempts = 60): Promise { for (let i = 0; i < attempts; i += 1) { try { const res = await fetch(url); if (res.ok) { return; } } catch { /* retry */ } await new Promise((r) => setTimeout(r, 2000)); } throw new Error(`服务未就绪: ${url}(请先 npm run dev:start)`); } function draftAddress( draft: { street: string; city: string; state: string }, ): QuoteRequestBody["pickup_address"] { const street = draft.street.trim(); const city = draft.city.trim(); const state = draft.state.trim(); return { street, city, state, zip: "", place_id: `draft_${street}_${city}_${state}`.replace(/\s+/g, "_").slice(0, 64), formatted_address: `${street}, ${city}, ${state}`, selected_from_suggestions: true, }; } function fmtMs(ms: number): string { if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; const m = Math.floor(ms / 60_000); const s = ((ms % 60_000) / 1000).toFixed(0); return `${m}m${s}s`; } type CaseResult = { id: string; route: string; ok: boolean; quoteId?: string; tierCount?: number; sourceType?: string; durationMs: number; pickupLabel?: string; deliveryLabel?: string; quotes?: Array<{ serviceLevel: string; rateOption: string; rawTotal: number; carrier?: string; }>; error?: string; }; async function runSystemQuote(pair: HumanDocPair): Promise { const started = Date.now(); const userPickup = { ...pair.pickup, zip: "" }; const userDelivery = { ...pair.delivery, zip: "" }; try { const cand = await hostFetchMothershipCandidates( BASE, TOKEN, CUSTOMER_ID, userPickup, userDelivery, ); if (cand.code !== 0 || !cand.data) { return { id: pair.id, route: pair.route, ok: false, durationMs: Date.now() - started, error: cand.message ?? "地址候选 API 失败", }; } const selectedPickup = pickBestMothershipCandidate( cand.data.pickup_candidates, userPickup, ); const selectedDelivery = pickBestMothershipCandidate( cand.data.delivery_candidates, userDelivery, ); if (!selectedPickup || !selectedDelivery) { return { id: pair.id, route: pair.route, ok: false, durationMs: Date.now() - started, error: "地址候选为空", }; } const body: QuoteRequestBody = { request_id: randomUUID(), customer_id: CUSTOMER_ID, quote_session_id: cand.data.quote_session_id, pickup_address: applyMothershipCandidate( draftAddress(userPickup), selectedPickup, ), delivery_address: applyMothershipCandidate( draftAddress(userDelivery), selectedDelivery, ), weight: { value: uniqueWeight(HUMAN_DOC_CARGO.weightLb), unit: "lb" }, dimensions: { length: HUMAN_DOC_CARGO.dimsIn.length, width: HUMAN_DOC_CARGO.dimsIn.width, height: HUMAN_DOC_CARGO.dimsIn.height, unit: "in", }, pallet_count: HUMAN_DOC_CARGO.palletCount, cargo_type: HUMAN_DOC_CARGO.cargoType, }; const created = await hostCreateQuote(BASE, TOKEN, body); if (created.code !== 0 || !created.data?.quote_id) { return { id: pair.id, route: pair.route, ok: false, durationMs: Date.now() - started, error: created.message ?? "POST /api/quotes 失败", }; } const quoteId = created.data.quote_id; let finalDetail = created.data; if (finalDetail.status === "processing") { const poll = await pollQuoteUntilDone( async () => { const detail = await hostGetQuote(BASE, TOKEN, CUSTOMER_ID, quoteId); if (detail.code !== 0) { return { ok: false, errorMessage: detail.message }; } return { ok: true, data: detail.data }; }, { timeoutMs: POLL_TIMEOUT_MS }, ); if (poll.type !== "done") { return { id: pair.id, route: pair.route, ok: false, quoteId, durationMs: Date.now() - started, error: poll.type === "timeout" ? `轮询超时(${fmtMs(POLL_TIMEOUT_MS)})` : poll.message, }; } finalDetail = poll.quote; } const tierCount = finalDetail?.quotes?.length ?? 0; const sourceType = String(finalDetail?.source_type ?? ""); const ok = finalDetail?.status === "done" && tierCount >= 1 && (sourceType === "rpa" || sourceType === "cache"); return { id: pair.id, route: pair.route, ok, quoteId, tierCount, sourceType, durationMs: Date.now() - started, pickupLabel: selectedPickup.display_label, deliveryLabel: selectedDelivery.display_label, quotes: (finalDetail?.quotes ?? []).map((q) => ({ serviceLevel: q.service_level, rateOption: q.rate_option, rawTotal: q.raw_total, carrier: q.carrier, })), error: ok ? undefined : `status=${finalDetail?.status} tiers=${tierCount} source=${sourceType}`, }; } catch (error) { return { id: pair.id, route: pair.route, ok: false, durationMs: Date.now() - started, error: error instanceof Error ? error.message : String(error), }; } } async function main(): Promise { const runId = randomUUID().slice(0, 8); const pairs = selectPairs(); if (!pairs.length) { console.error(`[probe:human-doc] 无匹配路线 PROBE_HUMAN_ONLY=${ONLY.join(",")}`); process.exit(1); } await ensureNativeInfraIfNeeded(); resetRedisClient(); await ensureRedisReady(); await waitForHttpOk(`${BASE}/`); const workerOk = await isAnyRpaWorkerHealthy(); console.log( `[probe:human-doc] 系统全链路探针 run=${runId} pairs=${pairs.length} worker=${workerOk ? "ok" : "未就绪"}`, ); console.log(`[probe:human-doc] API=${BASE} 策略=direct-first-widget-fallback`); const results: CaseResult[] = []; let pass = 0; for (const pair of pairs) { console.log(`\n[probe:human-doc] ${pair.id} ${pair.route}`); const outcome = await runSystemQuote(pair); results.push(outcome); if (outcome.ok) { pass += 1; const lowest = outcome.quotes?.find( (q) => q.serviceLevel === "standard" && q.rateOption === "lowest", ); console.log( `[PASS] quote=${outcome.quoteId} tiers=${outcome.tierCount} source=${outcome.sourceType} ${fmtMs(outcome.durationMs)} lowest=${lowest?.rawTotal ?? "—"}`, ); } else { console.log(`[FAIL] ${outcome.error ?? "unknown"} (${fmtMs(outcome.durationMs)})`); } } const report = { runId, mode: "system-api", sourceDoc: "docs/美国跨州货物运输报价汇总_edited3.docx", finishedAt: new Date().toISOString(), baseUrl: BASE, summary: { total: pairs.length, pass, fail: pairs.length - pass }, verdict: pass === pairs.length ? "PASS" : "FAIL", env: { quoteStrategy: "direct-first-widget-fallback", RPA_ADDRESS_MODE: process.env.RPA_ADDRESS_MODE ?? "", workerHealthy: workerOk, }, results, }; const outDir = join(process.cwd(), ".rpa", "human-doc-system-probe"); if (!existsSync(outDir)) { mkdirSync(outDir, { recursive: true }); } const outPath = join(outDir, `system-${runId}.json`); writeFileSync(outPath, JSON.stringify(report, null, 2), "utf8"); console.log(`\n[probe:human-doc] ${pass}/${pairs.length} 通过 | 报告: ${outPath}`); process.exit(pass === pairs.length ? 0 : 1); } main().catch((e) => { console.error(e); process.exit(1); });