/** * RPA 链路存在性证明(单次 HTTP 全路径,不改业务逻辑) * * 证明目标:候选 → 入队 → Worker 消费 → axel/quote 网络抓价 → DB sourceType=rpa * 前置:npm run dev:start && RPA_MOCK_MODE=false * mock 下游验证:RPA_ADDRESS_MODE=mock(自动跳过联想 API,用内置地址) * PowerShell:$env:RPA_ADDRESS_MODE="mock"; npm run prove:rpa-chain * 强制跳过联想:$env:PROOF_SKIP_CANDIDATES="true" * 轮询上限(默认 60s):$env:PROVE_POLL_TIMEOUT_MS="60000" * 用法:npm run prove:rpa-chain */ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { loadDevEnv } from "@/lib/dev-env"; import { getRpaAddressMode } from "@/lib/rpa/address-mode"; import { getRpaQuoteMode } from "@/lib/rpa/env"; import { buildSyntheticCandidates } from "./lib/proof-synthetic-candidates"; import { ensureNativeInfraIfNeeded } from "@/lib/infra/ensure-native-infra"; import { hostCreateQuote, hostFetchMothershipCandidates, hostGetQuote, hostPreheatQuoteSession, } from "@/lib/frontend/api-client"; import { applyMothershipCandidate } from "@/lib/frontend/mothership-address"; import type { QuoteRequestBody, MothershipAddressCandidate } from "@/lib/frontend/types"; import { pollQuoteUntilDone } from "@/hooks/use-quote-polling"; import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health"; import { prisma } from "@/lib/prisma"; import { ensureRedisReady, resetRedisClient } from "@/lib/redis"; import { recoverOrphanActiveJobs } from "@/workers/rpa/queue-recovery"; /** prove 脚本轮询上限(与生产 QUOTE_TIMEOUT_MS 解耦,默认 60s) */ const PROVE_POLL_TIMEOUT_MS = Number( process.env.PROVE_POLL_TIMEOUT_MS ?? 60_000, ); loadDevEnv(); const BASE = process.env.PROVE_BASE_URL ?? "http://localhost:3000"; const TOKEN = "demo-host-token"; const CUSTOMER_ID = "CUST_001"; const WORKER_LOG_DIR = join(process.cwd(), ".dev", "logs"); function decodeLogBuffer(buf: Buffer): string { if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) { return buf.subarray(2).toString("utf16le"); } if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) { return buf.subarray(2).toString("utf16le"); } // PowerShell 重定向常见无 BOM UTF-16LE const sample = buf.subarray(0, Math.min(buf.length, 200)); let nullCount = 0; for (let i = 1; i < sample.length; i += 2) { if (sample[i] === 0) { nullCount++; } } if (nullCount > sample.length / 4) { return buf.toString("utf16le"); } return buf.toString("utf8"); } function readLogFile(path: string, sinceByte = 0): string { const buf = readFileSync(path); const slice = buf.subarray(Math.min(sinceByte, buf.length)); return decodeLogBuffer(slice); } function snapshotWorkerLogOffsets(): Map { const snap = new Map(); if (!existsSync(WORKER_LOG_DIR)) { return snap; } const files = readdirSync(WORKER_LOG_DIR) .filter((name) => name.startsWith("worker-rpa") && name.endsWith(".log")) .map((name) => join(WORKER_LOG_DIR, name)); if (files.length === 0) { const legacy = join(WORKER_LOG_DIR, "worker-rpa.log"); if (existsSync(legacy)) { snap.set(legacy, readFileSync(legacy).length); } return snap; } for (const path of files) { snap.set(path, readFileSync(path).length); } return snap; } function readWorkerLogSince(snapshot: Map): string { if (snapshot.size === 0) { return readWorkerLogTail(0); } return [...snapshot.entries()] .map(([path, offset]) => readLogFile(path, offset)) .join("\n"); } function readWorkerLogTail(sinceByte: number): string { if (!existsSync(WORKER_LOG_DIR)) { return ""; } const files = readdirSync(WORKER_LOG_DIR) .filter((name) => name.startsWith("worker-rpa") && name.endsWith(".log")) .map((name) => join(WORKER_LOG_DIR, name)); if (files.length === 0) { const legacy = join(WORKER_LOG_DIR, "worker-rpa.log"); if (!existsSync(legacy)) { return ""; } return readLogFile(legacy, sinceByte); } return files.map((path) => readLogFile(path, sinceByte)).join("\n"); } type ProofAddress = { street: string; city: string; state: string; zip: string }; type ProofCargo = { pallet_count: number; weight_lb: number; length_in: number; width_in: number; height_in: number; cargo_type: QuoteRequestBody["cargo_type"]; }; /** 可选测试组(PROOF_PAIR_ID=01~10);默认 01 基线 */ const PROOF_PAIRS: Record< string, { label: string; pickup: Omit; delivery: Omit; cargo: ProofCargo } > = { "01": { label: "洛杉矶 CA → Farmers Branch TX(基线)", pickup: { street: "1234 Warehouse Street", city: "Los Angeles", state: "CA" }, delivery: { street: "5678 Distribution Way", city: "Farmers Branch", state: "TX" }, cargo: { pallet_count: 2, weight_lb: 500, length_in: 48, width_in: 40, height_in: 48, cargo_type: "general_freight" }, }, "02": { label: "西雅图 WA → 迈阿密 FL", pickup: { street: "3131 Western Ave", city: "Seattle", state: "WA" }, delivery: { street: "7000 NW 52nd St", city: "Miami", state: "FL" }, cargo: { pallet_count: 3, weight_lb: 550, length_in: 48, width_in: 40, height_in: 45, cargo_type: "general_freight" }, }, "06": { label: "丹佛 CO → 夏洛特 NC", pickup: { street: "4800 Race St", city: "Denver", state: "CO" }, delivery: { street: "9300 South Blvd", city: "Charlotte", state: "NC" }, cargo: { pallet_count: 3, weight_lb: 450, length_in: 48, width_in: 40, height_in: 44, cargo_type: "general_freight" }, }, "99": { label: "Key West FL → Los Angeles CA(Duval / Hollywood 通断测试)", pickup: { street: "Duval Street", city: "Key West", state: "FL" }, delivery: { street: "Hollywood Boulevard", city: "Los Angeles", state: "CA" }, cargo: { pallet_count: 2, weight_lb: 503, length_in: 48, width_in: 40, height_in: 48, cargo_type: "general_freight" }, }, "10": { label: "西雅图 Convention Center → 迈阿密 Beach Ocean Drive(widget)", pickup: { street: "Washington State Convention Center Main Garage, Pike Street", city: "Seattle", state: "WA", }, delivery: { street: "Ocean Drive", city: "Miami Beach", state: "FL", }, cargo: { pallet_count: 2, weight_lb: 500, length_in: 48, width_in: 40, height_in: 48, cargo_type: "general_freight", }, }, "11": { label: "真人录证 5519e0ea:293 Pike St Seattle → 826 Ocean Dr Miami Beach", pickup: { street: "293 Pike St", city: "Seattle", state: "WA" }, delivery: { street: "826 Ocean Dr", city: "Miami Beach", state: "FL" }, cargo: { pallet_count: 2, weight_lb: 500, length_in: 48, width_in: 40, height_in: 48, cargo_type: "general_freight", }, }, }; const PROOF_PAIR_ID = process.env.PROOF_PAIR_ID ?? "01"; const PROOF_PAIR = PROOF_PAIRS[PROOF_PAIR_ID] ?? PROOF_PAIRS["01"]!; /** 单托重量 + 宽域 nonce,避免 L2 与历史 prove 撞 cargo_hash */ const PROOF_WEIGHT_NONCE = Number( process.env.PROOF_WEIGHT_NONCE ?? (parseInt(randomUUID().replace(/-/g, ""), 16) % 9500) + 1, ); type Check = { id: string; ok: boolean; detail: string }; const checks: Check[] = []; function record(id: string, ok: boolean, detail: string): void { checks.push({ id, ok, detail }); console.log(`${ok ? "[PASS]" : "[FAIL]"} ${id}: ${detail}`); } const PROOF_DIR = join(process.cwd(), ".rpa"); async function ensureStackReady(): Promise { ensureNativeInfraIfNeeded(); resetRedisClient(); await ensureRedisReady(12, 1_000); try { await prisma.$queryRaw`SELECT 1`; } catch { throw new Error("MySQL 未响应(127.0.0.1:3307),请先 npm run dev:infra:start"); } const recovered = await recoverOrphanActiveJobs(); if (recovered > 0) { console.warn(`[prove] 已回收 ${recovered} 条 orphan 队列 job`); } const res = await fetch(BASE, { method: "GET", redirect: "manual" }); if (res.status <= 0) { throw new Error("Next 未响应,请先 npm run dev:start"); } if (!(await isAnyRpaWorkerHealthy())) { throw new Error("RPA Worker 无心跳,请先 npm run dev:start"); } } function isProofSkipCandidates(): boolean { return process.env.PROOF_SKIP_CANDIDATES === "true"; } async function main(): Promise { console.log("=== RPA 链路存在性证明(单次 HTTP)===\n"); if (process.env.RPA_MOCK_MODE === "true") { record("mock-off", false, "RPA_MOCK_MODE=true,无法证明真实 RPA"); process.exit(1); } record("mock-off", true, "RPA_MOCK_MODE≠true"); const addressMode = getRpaAddressMode(); const quoteMode = getRpaQuoteMode(); const skipCandidates = isProofSkipCandidates(); if (addressMode === "mock") { record( "address-mode", false, "RPA_ADDRESS_MODE=mock 不产生 MotherShip 真实报价,请改为 real", ); process.exit(1); } record("address-mode", true, "RPA_ADDRESS_MODE=real(MotherShip 真实 axel/quote)"); record("quote-mode", true, `RPA_QUOTE_MODE=${quoteMode}`); if (skipCandidates) { record( "candidates-skip", true, "跳过 MotherShip 联想 API(仅 PROOF_SKIP_CANDIDATES=true 时)", ); } await ensureStackReady(); record("infra", true, "Next + Redis + Worker 心跳就绪"); const logSnapshot = snapshotWorkerLogOffsets(); const runId = randomUUID().slice(0, 8); const startedAt = new Date().toISOString(); console.log(`[prove] 测试组 ${PROOF_PAIR_ID}:${PROOF_PAIR.label}`); const userPickup: ProofAddress = { ...PROOF_PAIR.pickup, zip: "" }; const userDelivery: ProofAddress = { ...PROOF_PAIR.delivery, zip: "" }; const proofCargo = PROOF_PAIR.cargo; function draftAddress(draft: typeof userPickup): 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, }; } let selectedPickup: MothershipAddressCandidate; let selectedDelivery: MothershipAddressCandidate; let quote_session_id: string | undefined; if (skipCandidates) { console.log("\n[1/4] 跳过联想 API,使用内置合成地址 …"); const syn = buildSyntheticCandidates( PROOF_PAIR_ID, userPickup, userDelivery, ); selectedPickup = syn.pickup; selectedDelivery = syn.delivery; record( "candidates-rpa", true, `synthetic pickup=${selectedPickup.display_label} delivery=${selectedDelivery.display_label}`, ); } else { console.log("\n[1/4] POST /api/addresses/mothership-candidates …"); const candRes = await hostFetchMothershipCandidates( BASE, TOKEN, CUSTOMER_ID, userPickup, userDelivery, ); if (candRes.code !== 0 || !candRes.data) { record("candidates-http", false, candRes.message ?? "联想 API 失败"); writeReport(runId, startedAt, null, checks); process.exit(1); } const data = candRes.data; quote_session_id = data.quote_session_id; const pickupOk = data.pickup_candidates.length > 0; const deliveryOk = data.delivery_candidates.length > 0; record( "candidates-rpa", pickupOk && deliveryOk, `pickup=${data.pickup_candidates.length} delivery=${data.delivery_candidates.length}`, ); selectedPickup = data.pickup_candidates[0] ?? null; selectedDelivery = data.delivery_candidates[0] ?? null; if (!selectedPickup || !selectedDelivery) { writeReport(runId, startedAt, null, checks); process.exit(1); } const userStreetNorm = `${userPickup.street}|${userDelivery.street}`; const confirmedNorm = `${selectedPickup.street ?? ""}|${selectedDelivery.street ?? ""}`; record( "candidates-not-raw-input", userStreetNorm !== confirmedNorm || selectedPickup.display_label !== userPickup.street, `用户=${userPickup.street} → 确认=${selectedPickup.display_label}`, ); } if (!selectedPickup || !selectedDelivery) { writeReport(runId, startedAt, null, checks); process.exit(1); } if (quote_session_id) { await hostPreheatQuoteSession(BASE, TOKEN, CUSTOMER_ID, quote_session_id); } console.log("\n[2/4] POST /api/quotes …"); let created: Awaited> | null = null; for (let attempt = 0; attempt < 6; attempt++) { const weightLb = PROOF_PAIR.cargo.weight_lb + PROOF_WEIGHT_NONCE + attempt * 17; const body: QuoteRequestBody = { request_id: randomUUID(), customer_id: CUSTOMER_ID, quote_session_id, pickup_address: applyMothershipCandidate( draftAddress(userPickup), selectedPickup, ), delivery_address: applyMothershipCandidate( draftAddress(userDelivery), selectedDelivery, ), weight: { value: weightLb, unit: "lb" }, dimensions: { length: proofCargo.length_in, width: proofCargo.width_in, height: proofCargo.height_in, unit: "in", }, pallet_count: proofCargo.pallet_count, cargo_type: proofCargo.cargo_type, }; created = await hostCreateQuote(BASE, TOKEN, body); if (created.code !== 0 || !created.data?.quote_id) { break; } if ( created.data.status === "done" && created.data.source_type === "cache" ) { console.warn( `[prove] L2 命中,换 weight=${weightLb} 重试 (${attempt + 1}/6)`, ); continue; } break; } if (!created || created.code !== 0 || !created.data?.quote_id) { record("quote-create", false, created?.message ?? "创建询价失败"); writeReport(runId, startedAt, null, checks); process.exit(1); } const quoteId = created.data.quote_id; const createStatus = created.data.status; const createSource = created.data.source_type; if (createStatus === "done" && createSource === "cache") { record( "quote-not-cache", false, `创建即 done + source_type=cache(L2 假阳性,非本次 RPA)`, ); writeReport(runId, startedAt, quoteId, checks); process.exit(1); } record( "quote-not-cache", createStatus === "processing", `quote_id=${quoteId} status=${createStatus} source=${createSource ?? "—"}`, ); console.log( `\n[3/4] 轮询 GET /api/quotes/${quoteId}(上限 ${PROVE_POLL_TIMEOUT_MS / 1000}s)…`, ); let finalDetail; if (createStatus === "done") { const detail = await hostGetQuote(BASE, TOKEN, CUSTOMER_ID, quoteId); finalDetail = detail.data; } else { 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: PROVE_POLL_TIMEOUT_MS }, ); if (poll.type !== "done") { record( "quote-final", false, poll.type === "timeout" ? "轮询超时" : poll.message, ); writeReport(runId, startedAt, quoteId, checks); process.exit(1); } finalDetail = poll.quote; } const tierCount = finalDetail?.quotes?.length ?? 0; const finalSource = finalDetail?.source_type; record( "quote-final-rpa", finalDetail?.status === "done" && finalSource === "rpa" && tierCount >= 4, `status=${finalDetail?.status} source=${finalSource} tiers=${tierCount}`, ); console.log("\n[4/4] 校验 Worker 日志 + DB …"); const newLog = readWorkerLogSince(logSnapshot); const quoteScopedLog = newLog .split(/\r?\n/) .filter((line) => line.includes(quoteId)) .join("\n"); const logChecks: Array<[string, RegExp]> = [ ["log-quote-consume", new RegExp(`quote_id=${quoteId} attempt=`)], ["log-axel-quote", /services\.mothership\.com\/axel\/quote|\[rpa\] direct axel quote:/], ["log-quote-done", new RegExp(`\\[rpa-worker\\] job .*quote_id=${quoteId}`)], ]; if (!skipCandidates) { if (quoteMode === "widget") { logChecks.push([ "log-place-commit", /axel\/location\/place\//, ]); } else { logChecks.push([ "log-direct-quote", /\[rpa\] direct axel quote:/, ]); } record( "log-candidates-job", true, "direct/widget 候选走 axel API(不经 worker-rpa)", ); } record( "log-no-pickup-lost", !/pickupLostAfterDelivery=true/.test(newLog), "worker 日志不得出现 pickupLostAfterDelivery=true", ); record( "log-no-fixture", !/mock-chain-fixture/.test(quoteScopedLog || newLog), "禁止 mock-chain-fixture 占位价(仅本次 quote 日志)", ); for (const [id, pattern] of logChecks) { record(id, pattern.test(newLog), pattern.source); } const dbRec = await prisma.quoteRecord.findFirst({ where: { quoteId }, select: { status: true, sourceType: true, quotesJson: true, createdAt: true, }, }); const dbTierCount = Array.isArray(dbRec?.quotesJson) ? dbRec.quotesJson.length : 0; record( "db-rpa", dbRec?.status === "done" && dbRec?.sourceType === "rpa" && dbTierCount >= 4, dbRec ? `status=${dbRec.status} source=${dbRec.sourceType} tiers=${dbTierCount}` : "无记录", ); const reportPath = writeReport(runId, startedAt, quoteId, checks, { proofPairId: PROOF_PAIR_ID, proofPairLabel: PROOF_PAIR.label, rpaAddressMode: addressMode, rpaQuoteMode: quoteMode, proofSkipCandidates: skipCandidates, userPickup, userDelivery, confirmedPickup: selectedPickup.display_label, confirmedDelivery: selectedDelivery.display_label, quote_session_id, tiers: finalDetail?.quotes?.map((q) => ({ tier: `${q.service_level}/${q.rate_option}`, final_total: q.final_total, })), workerLogSnippet: newLog.slice(-4000), }); const allPass = checks.every((c) => c.ok); console.log(`\n证据文件:${reportPath}`); if (allPass) { console.log("\n=== VERDICT: PASS(HTTP 全链路 + Worker 日志 + DB 一致)==="); console.log( "下一步:人工打开 MotherShip 同候选地址核对档位/金额(本脚本不替代人工验收)", ); process.exit(0); } console.log("\n=== VERDICT: FAIL(链路未完整证明,勿改业务逻辑)==="); process.exit(1); } function writeReport( runId: string, startedAt: string, quoteId: string | null, checks: Check[], extra?: Record, ): string { mkdirSync(PROOF_DIR, { recursive: true }); const path = join(PROOF_DIR, `chain-proof-${runId}.json`); writeFileSync( path, JSON.stringify( { runId, startedAt, finishedAt: new Date().toISOString(), quoteId, verdict: checks.every((c) => c.ok) ? "PASS" : "FAIL", checks, ...extra, }, null, 2, ), "utf8", ); return path; } main() .catch((error) => { console.error("[FAIL] unexpected:", error); process.exit(1); }) .finally(async () => { await prisma.$disconnect().catch(() => undefined); });