|
|
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<void> {
|
|
|
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<QuoteSessionRecord | null> {
|
|
|
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<string> {
|
|
|
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<string | null> {
|
|
|
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);
|
|
|
}
|