import { randomUUID } from "node:crypto"; import { ADDRESS_CANDIDATES_POLL_MS, ADDRESS_CANDIDATES_RESULT_PREFIX, ADDRESS_CANDIDATES_RESULT_TTL_SECONDS, } from "@/lib/constants/address-candidates-queue"; import { ADDRESS_CANDIDATES_TIMEOUT_MS } from "@/lib/constants/rpa"; import { ensureNativeInfraIfNeeded } from "@/lib/infra/ensure-native-infra"; import { ensureRedisReady, getRedis, resetRedisClient, } from "@/lib/redis"; import { isParkedSessionEnabled } from "@/lib/rpa/env"; import { spawnFetchMothershipAddressCandidates } from "@/lib/rpa/spawn-address-candidates"; import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health"; import type { AddressLookupInput, MothershipCandidatesResult, } from "@/modules/address/types"; import { RpaError, type RpaErrorCode } from "@/modules/rpa/errors"; import { addAddressCandidatesJob } from "@/workers/rpa/queue"; import type { AddressCandidatesJobData } from "@/modules/address/candidates-queue-types"; type StoredCandidatesSuccess = { ok: true; data: MothershipCandidatesResult; }; type StoredCandidatesFailure = { ok: false; code: RpaErrorCode; message: string; retryable?: boolean; }; type StoredCandidatesResult = StoredCandidatesSuccess | StoredCandidatesFailure; function resultKey(jobId: string): string { return `${ADDRESS_CANDIDATES_RESULT_PREFIX}${jobId}`; } async function ensureCandidatesRedisReady(): Promise { try { await ensureRedisReady(10, 800); } catch { ensureNativeInfraIfNeeded(); resetRedisClient(); await ensureRedisReady(15, 1_000); } } /** 快速探测是否可走 BullMQ(Redis + Worker 心跳均可用) */ async function canUseCandidatesQueue(): Promise { try { await ensureRedisReady(5, 500); return await isAnyRpaWorkerHealthy(); } catch { return false; } } export async function saveAddressCandidatesResult( jobId: string, payload: StoredCandidatesResult, ): Promise { await ensureCandidatesRedisReady(); await getRedis().setex( resultKey(jobId), ADDRESS_CANDIDATES_RESULT_TTL_SECONDS, JSON.stringify(payload), ); } async function readAddressCandidatesResult( jobId: string, ): Promise { const raw = await getRedis().get(resultKey(jobId)); if (!raw) { return null; } return JSON.parse(raw) as StoredCandidatesResult; } async function requestViaSpawn(input: { pickup: AddressLookupInput; delivery: AddressLookupInput; }): Promise { return spawnFetchMothershipAddressCandidates(input); } /** 经 RPA Worker 执行地址候选;Worker/队列不可用时回退 spawn 子进程 */ export async function requestMothershipCandidatesViaWorker(input: { pickup: AddressLookupInput; delivery: AddressLookupInput; }): Promise { if (process.env.RPA_QUEUE_ENABLED === "false") { return requestViaSpawn(input); } const parkedSessionsEnabled = isParkedSessionEnabled(); const useQueue = await canUseCandidatesQueue(); if (!useQueue) { if (parkedSessionsEnabled) { throw new RpaError( "PAGE_LOAD_TIMEOUT", "RPA Worker 未运行,无法使用单会话加速。请执行 npm run worker:rpa 或 npm run dev:start", { retryable: true }, ); } console.warn( "[candidates] Redis/Worker 不可用,改用子进程执行地址联想", ); return requestViaSpawn(input); } await ensureCandidatesRedisReady(); const jobId = randomUUID(); await addAddressCandidatesJob({ jobId, pickup: input.pickup, delivery: input.delivery, }); const deadline = Date.now() + ADDRESS_CANDIDATES_TIMEOUT_MS; while (Date.now() < deadline) { try { const stored = await readAddressCandidatesResult(jobId); if (stored?.ok) { return stored.data; } if (stored && !stored.ok) { throw new RpaError(stored.code, stored.message, { retryable: stored.retryable ?? false, }); } } catch (error) { if (error instanceof RpaError) { throw error; } ensureNativeInfraIfNeeded(); resetRedisClient(); await ensureCandidatesRedisReady(); } await new Promise((resolve) => setTimeout(resolve, ADDRESS_CANDIDATES_POLL_MS)); } throw new RpaError( "PAGE_LOAD_TIMEOUT", "MotherShip 地址联想超时,请确认 RPA Worker 与 Redis 已启动(npm run dev:start)", { retryable: true }, ); }