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