import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import Redis from "ioredis"; import type { BrowserContext } from "playwright"; import { QUOTE_SESSION_REDIS_PREFIX, QUOTE_SESSION_TTL_SECONDS, } from "@/lib/constants/quote-session"; export type QuoteSessionAddressInput = { street: string; city: string; state: string; zip: string; }; export type QuoteSessionRecord = { sessionId: string; storagePath: string; createdAt: string; pickupKey: string; deliveryKey: string; }; function getSessionDir(): string { return path.join(process.cwd(), ".rpa", "sessions"); } function sessionFilePath(sessionId: string): string { return path.join(getSessionDir(), `${sessionId}.json`); } function redisKey(sessionId: string): string { return `${QUOTE_SESSION_REDIS_PREFIX}${sessionId}`; } function addressLookupKey(address: QuoteSessionAddressInput): string { return [address.street, address.city, address.state, address.zip] .map((part) => part.trim().toLowerCase()) .join("|"); } async function writeQuoteSessionToRedis( sessionId: string, record: QuoteSessionRecord, ): Promise { const url = process.env.REDIS_URL?.trim(); if (!url) { return; } const client = new Redis(url, { family: 4, connectTimeout: 3_000, maxRetriesPerRequest: 1, lazyConnect: true, retryStrategy: () => null, }); try { await client.connect(); await client.setex( redisKey(sessionId), QUOTE_SESSION_TTL_SECONDS, JSON.stringify(record), ); } finally { await client.quit().catch(() => undefined); } } async function readQuoteSessionFromRedis( sessionId: string, ): Promise { const url = process.env.REDIS_URL?.trim(); if (!url) { return null; } const client = new Redis(url, { family: 4, connectTimeout: 3_000, maxRetriesPerRequest: 1, lazyConnect: true, retryStrategy: () => null, }); try { await client.connect(); const raw = await client.get(redisKey(sessionId)); return raw ? (JSON.parse(raw) as QuoteSessionRecord) : null; } finally { await client.quit().catch(() => undefined); } } /** 候选 RPA 结束后持久化 browser storageState,供询价 job 复用 */ export async function saveQuoteSessionFromContext( context: BrowserContext, input: { pickup: QuoteSessionAddressInput; delivery: QuoteSessionAddressInput; }, ): Promise { fs.mkdirSync(getSessionDir(), { recursive: true }); const sessionId = randomUUID(); const storagePath = sessionFilePath(sessionId); await context.storageState({ path: storagePath }); const record: QuoteSessionRecord = { sessionId, storagePath, createdAt: new Date().toISOString(), pickupKey: addressLookupKey(input.pickup), deliveryKey: addressLookupKey(input.delivery), }; try { await writeQuoteSessionToRedis(sessionId, record); } catch (error) { console.warn( "[quote-session] Redis 写入失败,仍保留本地会话文件", error instanceof Error ? error.message : error, ); } console.log(`[quote-session] 已保存 session=${sessionId}`); return sessionId; } /** 询价 job 解析会话 storageState 路径(Redis 不可用时回退本地文件) */ export async function resolveQuoteSessionStoragePath( sessionId: string | undefined, ): Promise { const id = sessionId?.trim(); if (!id) { return null; } try { const record = await readQuoteSessionFromRedis(id); if (record && fs.existsSync(record.storagePath)) { return record.storagePath; } } catch { /* 回退文件 */ } const fallback = sessionFilePath(id); return fs.existsSync(fallback) ? fallback : null; } export function hashQuoteSessionId(sessionId: string): string { return createHash("sha256").update(sessionId).digest("hex").slice(0, 12); }