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.

244 lines
6.4 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 全栈验收(真实环境,非 mock
*
* 通过标准(全部 exit 0
* 1. WSL mysql:3307 + redis:6379 可达
* 2. smoke:connect 通过
* 3. worker:rpa 启动后持续监控 30s≥2 次心跳更新,且全程无 Redis 致命错误
* 4. probe:rpa 返回 4 档报价
*
* 用法npm run verify:rpa-stack
*/
import { spawn, execSync } from "node:child_process";
import { loadDevEnv, readInfraEnvOverrides } from "@/lib/dev-env";
loadDevEnv();
import { ensureNativeInfraIfNeeded } from "@/lib/infra/ensure-native-infra";
import {
ensureRedisReady,
getRedis,
probeFreshRedisConnection,
resetRedisClient,
} from "@/lib/redis";
import { executeProbeQuote } from "@/lib/rpa/run-probe";
import { validateProbeItems } from "@/lib/rpa/probe-validation";
import { prisma } from "@/lib/prisma";
const WORKER_WARMUP_MS = 30_000;
const WORKER_HEALTH_POLL_MS = 2_000;
/** 首次心跳后至少再观察此时长,避免「刚连上就断」误报 PASS */
const MIN_MONITOR_AFTER_FIRST_HB_MS = 15_000;
const WORKER_FATAL_PATTERNS: RegExp[] = [
/ECONNREFUSED.*6379/i,
/MaxRetriesPerRequestError/i,
/\[rpa-worker\] 心跳失败/i,
/\[rpa-worker\] 熔断\/暂停检查失败/i,
/\[rpa-worker\] BullMQ 连接错误/i,
];
function fail(step: string, detail: string): never {
console.error(`[FAIL] ${step}: ${detail}`);
process.exit(1);
}
function pass(step: string, detail: string): void {
console.log(`[PASS] ${step}: ${detail}`);
}
function findWorkerFatalError(text: string): string | null {
for (const pattern of WORKER_FATAL_PATTERNS) {
if (pattern.test(text)) {
return pattern.source;
}
}
return null;
}
function reloadDevEnvAfterInfra(): void {
loadDevEnv();
}
async function stepInfra(): Promise<void> {
if (process.env.RPA_MOCK_MODE === "true") {
fail("infra", "RPA_MOCK_MODE=true请设 false 做真实验收");
}
try {
if (process.platform === "win32") {
execSync("npm run dev:infra:start", {
stdio: "inherit",
cwd: process.cwd(),
timeout: 120_000,
});
reloadDevEnvAfterInfra();
} else {
ensureNativeInfraIfNeeded();
}
} catch (error) {
fail(
"infra",
error instanceof Error ? error.message : "dev:infra:start 失败",
);
}
resetRedisClient();
await ensureRedisReady();
pass(
"infra",
`mysql + redis 就绪REDIS_URL=${process.env.REDIS_URL ?? "未配置"}`,
);
}
async function stepSmokeConnect(): Promise<void> {
await prisma.$connect();
const userCount = await prisma.sysUser.count();
await prisma.$disconnect();
const pong = await getRedis().ping();
if (pong !== "PONG") {
fail("smoke-connect", `redis ping=${pong}`);
}
pass("smoke-connect", `prisma sys_user=${userCount}, redis PONG`);
}
async function stepWorkerHealth(): Promise<void> {
const workerId = `verify-${process.pid}`;
const logChunks: string[] = [];
reloadDevEnvAfterInfra();
resetRedisClient();
await probeFreshRedisConnection();
await ensureRedisReady();
const workerEnv = {
...process.env,
...readInfraEnvOverrides(),
RPA_WORKER_ID: workerId,
};
const workerProc = spawn("npx", ["tsx", "workers/rpa/index.ts"], {
cwd: process.cwd(),
env: workerEnv,
stdio: ["ignore", "pipe", "pipe"],
shell: true,
});
workerProc.stdout?.on("data", (buf) => {
logChunks.push(buf.toString());
});
workerProc.stderr?.on("data", (buf) => {
logChunks.push(buf.toString());
});
const deadline = Date.now() + WORKER_WARMUP_MS;
let sawWorkerStart = false;
let firstHbAt: number | null = null;
let lastHbTs: string | null = null;
let hbUpdates = 0;
const killWorker = (): void => {
if (!workerProc.killed) {
workerProc.kill("SIGTERM");
}
};
while (Date.now() < deadline) {
const text = logChunks.join("");
const fatal = findWorkerFatalError(text);
if (fatal) {
killWorker();
fail(
"worker-health",
`worker 出现 Redis 致命错误(${fatal}\n${text.slice(-1200)}`,
);
}
if (text.includes("[rpa-worker] 启动 worker_id=")) {
sawWorkerStart = true;
}
if (sawWorkerStart) {
try {
resetRedisClient();
await ensureRedisReady(3, 500);
const key = `worker:heartbeat:${workerId}`;
const raw = await getRedis().get(key);
if (raw && raw !== lastHbTs) {
if (lastHbTs !== null) {
hbUpdates += 1;
}
lastHbTs = raw;
if (firstHbAt === null) {
firstHbAt = Date.now();
}
}
} catch {
/* 继续轮询 */
}
}
const monitoredEnough =
firstHbAt !== null &&
Date.now() - firstHbAt >= MIN_MONITOR_AFTER_FIRST_HB_MS &&
hbUpdates >= 1;
if (monitoredEnough) {
killWorker();
pass(
"worker-health",
`稳定观察 ${MIN_MONITOR_AFTER_FIRST_HB_MS / 1000}s心跳更新 ${hbUpdates + 1}worker_id=${workerId}`,
);
return;
}
await new Promise((r) => setTimeout(r, WORKER_HEALTH_POLL_MS));
}
killWorker();
const text = logChunks.join("");
if (!sawWorkerStart) {
fail("worker-health", `worker 未启动\n${text.slice(-1200)}`);
}
if (firstHbAt === null) {
fail("worker-health", `30s 内未写入心跳\n${text.slice(-1200)}`);
}
fail(
"worker-health",
`首次心跳后未满 ${MIN_MONITOR_AFTER_FIRST_HB_MS / 1000}s 或心跳未续期updates=${hbUpdates}\n${text.slice(-1200)}`,
);
}
async function stepProbe(): Promise<void> {
resetRedisClient();
await ensureRedisReady();
const result = await executeProbeQuote();
const validation = validateProbeItems(result.items);
if (!validation.ok) {
fail("probe-rpa", validation.message);
}
pass(
"probe-rpa",
`${result.items.length} 档报价 source=${result.sourceType}`,
);
}
async function main(): Promise<void> {
console.log("=== RPA 全栈验收开始 ===\n");
console.log(
"目标infra 就绪 → DB/Redis 连通 → worker 30s 无 Redis 致命错误 → probe 4 档报价\n",
);
await stepInfra();
await stepSmokeConnect();
await stepWorkerHealth();
await stepProbe();
console.log("\n=== RPA 全栈验收通过 ===");
}
main()
.catch((error) => {
fail("unexpected", error instanceof Error ? error.message : String(error));
})
.finally(async () => {
resetRedisClient();
await prisma.$disconnect().catch(() => undefined);
});