You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
chajia/lib/rpa/quote-session-store.ts

157 lines
3.9 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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