/** * 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 { 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 { 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 { 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 { 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 { 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); });