|
|
/**
|
|
|
* 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<string, number> {
|
|
|
const snap = new Map<string, number>();
|
|
|
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, number>): 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<ProofAddress, "zip">; delivery: Omit<ProofAddress, "zip">; 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<void> {
|
|
|
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<void> {
|
|
|
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<ReturnType<typeof hostCreateQuote>> | 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, unknown>,
|
|
|
): 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);
|
|
|
});
|