You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

609 lines
19 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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 CADuval / 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 Drivewidget",
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: "真人录证 5519e0ea293 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=realMotherShip 真实 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=cacheL2 假阳性,非本次 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: PASSHTTP 全链路 + 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);
});