930 lines
27 KiB
TypeScript

import type {
ConfirmTaskPayload,
CreateTaskInput,
PlatformId
} from "@cross-ai/domain";
import cors from "@fastify/cors";
import Fastify from "fastify";
import { resolve } from "node:path";
import { rankJdCandidatesForKeyword } from "./platforms/jd/keyword-preview";
import { JdLiveSessionService, isJdLiveError } from "./platforms/jd/live-session";
import { JdSessionManagerService } from "./platforms/jd/session-manager";
import type {
JdLiveService,
JdSearchMode,
JdSessionManager,
JdSessionManagerAutoMode
} from "./platforms/jd/types";
import {
JdOpsQrLoginService,
TmallOpsQrLoginService,
type OpsQrLoginController
} from "./ops/qr-login";
import { TmallLiveSessionService, isTmallLiveError } from "./platforms/tmall/live-session";
import { TmallSessionManagerService } from "./platforms/tmall/session-manager";
import type {
TmallLiveService,
TmallSessionManager,
TmallSessionManagerConfigInput
} from "./platforms/tmall/types";
import { InMemoryTaskStore } from "./store";
export function createServer(
options: {
storagePath?: string;
jdLiveService?: JdLiveService;
jdSessionManager?: JdSessionManager;
jdQrLoginService?: OpsQrLoginController;
tmallLiveService?: TmallLiveService;
tmallSessionManager?: TmallSessionManager;
tmallQrLoginService?: OpsQrLoginController;
executionMode?: "synchronous" | "background";
} = {}
) {
const app = Fastify({ logger: false });
const jdLiveService = options.jdLiveService ?? new JdLiveSessionService();
const tmallLiveService = options.tmallLiveService ?? new TmallLiveSessionService();
const storagePath =
options.storagePath ??
process.env.TASK_STORE_PATH ??
(process.env.NODE_ENV === "test"
? undefined
: resolve(process.cwd(), ".data", "task-store.json"));
const store = new InMemoryTaskStore({
storagePath,
jdLiveService,
tmallLiveService,
executionMode: options.executionMode
});
const jdSessionManager =
options.jdSessionManager ??
new JdSessionManagerService(jdLiveService, {
onSessionReady: () => {
store.notifyManagedSessionReady("jd");
},
onSessionUnavailable: () => {
store.clearPlatformSession("jd");
}
});
const tmallSessionManager =
options.tmallSessionManager ??
new TmallSessionManagerService(tmallLiveService, {
onSessionReady: () => {
store.preparePlatform("tmall");
},
onSessionUnavailable: () => {
store.clearPlatformSession("tmall");
}
});
const jdQrLoginService =
options.jdQrLoginService ??
new JdOpsQrLoginService(jdSessionManager, {
resolveSearchQuery: () => jdSessionManager.getState().heartbeatQuery
});
const tmallQrLoginService =
options.tmallQrLoginService ??
new TmallOpsQrLoginService(tmallSessionManager, {
resolveTargetItemId: () => tmallSessionManager.getState().heartbeatItemId
});
if (
jdLiveService instanceof JdLiveSessionService &&
jdQrLoginService instanceof JdOpsQrLoginService
) {
jdLiveService.setBrowserPreviewProvider(jdQrLoginService);
}
store.setJdSessionManager(jdSessionManager);
store.setTmallSessionManager(tmallSessionManager);
if (jdLiveService.getSessionSummary().configured && !store.getSession("jd").ready) {
store.notifyManagedSessionReady("jd");
}
if (tmallLiveService.getSessionSummary().configured && !store.getSession("tmall").ready) {
store.preparePlatform("tmall");
}
app.addHook("onClose", async () => {
jdSessionManager.shutdown();
tmallSessionManager.shutdown();
await jdQrLoginService.shutdown();
await tmallQrLoginService.shutdown();
});
app.register(cors, { origin: true });
app.get("/api/health", async () => ({
status: "ok",
service: "cross-platform-products-ai-analyst-api"
}));
app.get("/api/platforms/readiness", async () => ({
platforms: store.getPlatformReadiness().map((platform) =>
platform.platform === "jd"
? {
...platform,
reason: jdSessionManager.getState().publicNote
}
: platform.platform === "tmall"
? {
...platform,
reason: tmallSessionManager.getState().publicNote
}
: platform
)
}));
app.get("/api/sessions", async () => ({
sessions: store.listSessions()
}));
app.get<{
Params: { platform: PlatformId };
}>("/api/sessions/:platform", async (request, reply) => {
try {
const session = store.getSession(request.params.platform);
return { session };
} catch {
reply.code(404);
return { message: "Session not found." };
}
});
app.post<{
Params: { platform: PlatformId };
}>("/api/platforms/:platform/prepare", async (request, reply) => {
const session = store.preparePlatform(request.params.platform);
reply.code(200);
return {
platform: session.platform,
session_ready: session.ready,
status: session.status,
last_prepared_at: session.lastPreparedAt,
expires_at: session.expiresAt,
encrypted_snapshot_available: session.encryptedSnapshotAvailable
};
});
app.get("/api/platforms/jd/live-session", async () => ({
session: jdLiveService.getSessionSummary()
}));
app.get("/api/ops/jd/session-manager", async () => ({
manager: jdSessionManager.getState()
}));
app.post<{
Body: {
enabled?: boolean;
autoLoginMode?: JdSessionManagerAutoMode;
loginCommand?: string | null;
browserProfilePath?: string | null;
heartbeatQuery?: string | null;
account?: string | null;
password?: string | null;
checkIntervalMs?: number | null;
runnerTimeoutMs?: number | null;
};
}>("/api/ops/jd/session-manager/config", async (request, reply) => {
try {
const manager = jdSessionManager.configure(request.body);
reply.code(200);
return { manager };
} catch (error) {
reply.code(400);
return {
message:
error instanceof Error ? error.message : "Invalid JD ops session manager config."
};
}
});
app.delete("/api/ops/jd/session-manager/config", async () => ({
manager: jdSessionManager.clearConfig()
}));
app.post("/api/ops/jd/session-manager/check", async (_request, reply) => {
try {
const result = await jdSessionManager.runHealthCheck("ops");
reply.code(200);
return result;
} catch (error) {
reply.code(502);
return {
message:
error instanceof Error ? error.message : "JD ops health check failed."
};
}
});
app.post("/api/ops/jd/session-manager/recover", async (_request, reply) => {
try {
const result = await jdSessionManager.runAutoRecovery("ops");
reply.code(200);
return result;
} catch (error) {
reply.code(502);
return {
message:
error instanceof Error ? error.message : "JD ops auto recovery failed."
};
}
});
app.post<{
Body: {
cookieHeader: string;
userAgent?: string;
searchApiTemplateUrl?: string;
detailTemplateUrl?: string;
reviewsTemplateUrl?: string;
searchReferer?: string;
detailReferer?: string;
};
}>("/api/ops/jd/session-manager/session", async (request, reply) => {
try {
await jdSessionManager.importManualSession(request.body, "ops-manual");
const result = await jdSessionManager.runHealthCheck("ops-manual");
reply.code(200);
return {
manager: result.state,
recovered: result.recovered
};
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 400);
return {
message: error instanceof Error ? error.message : "Invalid JD ops live session payload."
};
}
});
app.delete("/api/ops/jd/session-manager/session", async () => ({
manager: jdSessionManager.clearManagedSession("ops-manual-clear")
}));
app.get("/api/ops/jd/session-manager/qr-login", async () => ({
qrLogin: jdQrLoginService.getState()
}));
app.post("/api/ops/jd/session-manager/qr-login/start", async (_request, reply) => {
try {
const qrLogin = await jdQrLoginService.start();
reply.code(200);
return { qrLogin };
} catch (error) {
reply.code(500);
return {
message: error instanceof Error ? error.message : "JD QR login start failed."
};
}
});
app.post("/api/ops/jd/session-manager/qr-login/cancel", async (_request, reply) => {
const qrLogin = await jdQrLoginService.cancel("ops-cancel");
reply.code(200);
return { qrLogin };
});
app.post("/api/ops/jd/session-manager/qr-login/resume", async (_request, reply) => {
try {
const qrLogin = await jdQrLoginService.resumeManualRecovery();
reply.code(200);
return { qrLogin };
} catch (error) {
reply.code(500);
return {
message: error instanceof Error ? error.message : "JD QR login manual recovery failed."
};
}
});
app.get("/api/ops/tmall/session-manager", async () => ({
manager: tmallSessionManager.getState()
}));
app.post<{
Body: TmallSessionManagerConfigInput;
}>("/api/ops/tmall/session-manager/config", async (request, reply) => {
try {
const manager = tmallSessionManager.configure(request.body);
reply.code(200);
return { manager };
} catch (error) {
reply.code(400);
return {
message:
error instanceof Error ? error.message : "Invalid Tmall ops session manager config."
};
}
});
app.delete("/api/ops/tmall/session-manager/config", async () => ({
manager: tmallSessionManager.clearConfig()
}));
app.post("/api/ops/tmall/session-manager/check", async (_request, reply) => {
try {
const result = await tmallSessionManager.runHealthCheck("ops");
reply.code(200);
return result;
} catch (error) {
reply.code(502);
return {
message:
error instanceof Error ? error.message : "Tmall ops health check failed."
};
}
});
app.post<{
Body: {
cookieHeader: string;
userAgent?: string;
detailTemplateUrl?: string;
reviewsTemplateUrl?: string;
detailReferer?: string;
};
}>("/api/ops/tmall/session-manager/session", async (request, reply) => {
try {
const manager = await tmallSessionManager.importManualSession(request.body, "ops-manual");
reply.code(200);
return { manager };
} catch (error) {
reply.code(isTmallLiveError(error) ? error.statusCode : 400);
return {
message:
error instanceof Error ? error.message : "Invalid Tmall ops live session payload."
};
}
});
app.delete("/api/ops/tmall/session-manager/session", async () => ({
manager: tmallSessionManager.clearManagedSession("ops-manual-clear")
}));
app.get("/api/ops/tmall/session-manager/qr-login", async () => ({
qrLogin: tmallQrLoginService.getState()
}));
app.post("/api/ops/tmall/session-manager/qr-login/start", async (_request, reply) => {
try {
const qrLogin = await tmallQrLoginService.start();
reply.code(200);
return { qrLogin };
} catch (error) {
reply.code(500);
return {
message: error instanceof Error ? error.message : "Tmall QR login start failed."
};
}
});
app.post("/api/ops/tmall/session-manager/qr-login/cancel", async (_request, reply) => {
const qrLogin = await tmallQrLoginService.cancel("ops-cancel");
reply.code(200);
return { qrLogin };
});
app.get("/api/platforms/tmall/live-session", async () => ({
session: tmallLiveService.getSessionSummary()
}));
app.post<{
Body: {
cookieHeader: string;
userAgent?: string;
searchApiTemplateUrl?: string;
detailTemplateUrl?: string;
reviewsTemplateUrl?: string;
searchReferer?: string;
detailReferer?: string;
};
}>("/api/platforms/jd/live-session", async (request, reply) => {
try {
await jdSessionManager.importManualSession(request.body, "legacy-live-session");
await jdSessionManager.runHealthCheck("legacy-live-session");
reply.code(200);
return { session: jdLiveService.getSessionSummary() };
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 400);
return {
message: error instanceof Error ? error.message : "Invalid JD live session payload."
};
}
});
app.post<{
Body: {
cookieHeader: string;
userAgent?: string;
detailTemplateUrl?: string;
reviewsTemplateUrl?: string;
detailReferer?: string;
};
}>("/api/platforms/tmall/live-session", async (request, reply) => {
try {
await tmallSessionManager.importManualSession(request.body, "legacy-live-session");
const session = tmallLiveService.getSessionSummary();
reply.code(200);
return { session };
} catch (error) {
reply.code(isTmallLiveError(error) ? error.statusCode : 400);
return {
message: error instanceof Error ? error.message : "Invalid Tmall live session payload."
};
}
});
app.delete("/api/platforms/jd/live-session", async (_request, reply) => {
jdSessionManager.clearManagedSession("legacy-live-session-clear");
reply.code(204);
return null;
});
app.delete("/api/platforms/tmall/live-session", async (_request, reply) => {
tmallSessionManager.clearManagedSession("legacy-live-session-clear");
reply.code(204);
return null;
});
app.delete<{
Params: { platform: PlatformId };
}>("/api/sessions/:platform", async (request, reply) => {
try {
store.clearPlatformSession(request.params.platform);
if (request.params.platform === "jd") {
jdLiveService.clearSession();
}
if (request.params.platform === "tmall") {
tmallLiveService.clearSession();
}
reply.code(204);
return null;
} catch {
reply.code(404);
return { message: "Session not found." };
}
});
app.post<{
Body: CreateTaskInput;
}>("/api/tasks", async (request, reply) => {
const task = await store.createTask(request.body);
reply.code(201);
return { task };
});
app.get<{
Params: { taskId: string };
}>("/api/tasks/:taskId", async (request, reply) => {
const task = store.getTask(request.params.taskId);
if (!task) {
reply.code(404);
return { message: "Task not found." };
}
return { task };
});
app.delete<{
Params: { taskId: string };
}>("/api/tasks/:taskId", async (request, reply) => {
try {
store.deleteTask(request.params.taskId);
reply.code(204);
return null;
} catch {
reply.code(404);
return { message: "Task not found." };
}
});
app.get<{
Params: { taskId: string };
}>("/api/tasks/:taskId/candidates", async (request, reply) => {
const candidates = store.getCandidates(request.params.taskId);
if (!candidates) {
reply.code(404);
return { message: "Task not found." };
}
return { candidates };
});
app.get<{
Params: { taskId: string };
}>("/api/tasks/:taskId/strategy-attempts", async (request, reply) => {
const attempts = store.getTaskStrategyAttempts(request.params.taskId);
if (!attempts) {
reply.code(404);
return { message: "Task not found." };
}
return { attempts };
});
app.get<{
Params: { taskId: string };
}>("/api/tasks/:taskId/audit", async (request, reply) => {
const audit = store.getTaskAuditLogs(request.params.taskId);
if (!audit) {
reply.code(404);
return { message: "Task not found." };
}
return { audit };
});
app.post<{
Params: { taskId: string };
Body: ConfirmTaskPayload;
}>("/api/tasks/:taskId/confirm", async (request, reply) => {
try {
const task = await store.confirmTask(request.params.taskId, request.body);
return { task };
} catch {
reply.code(404);
return { message: "Task not found." };
}
});
app.post<{
Params: { taskId: string; platform: PlatformId };
}>("/api/tasks/:taskId/platforms/:platform/retry", async (request, reply) => {
try {
const task = await store.retryPlatform(request.params.taskId, request.params.platform);
return { task };
} catch {
reply.code(404);
return { message: "Task not found." };
}
});
app.get<{
Params: { taskId: string };
Querystring: { version?: string };
}>("/api/tasks/:taskId/report", async (request, reply) => {
const version = request.query.version ? Number(request.query.version) : undefined;
const report = store.getReport(request.params.taskId, version);
if (!report) {
reply.code(404);
return { message: "Report not found." };
}
return { report };
});
app.get<{
Querystring: { query?: string; mode?: JdSearchMode };
}>("/api/platforms/jd/live-search-preview", async (request, reply) => {
try {
const query = request.query.query?.trim();
if (!query) {
reply.code(400);
return { message: "query is required." };
}
const preview = await jdLiveService.previewSearch(query, request.query.mode);
return { preview };
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "JD live search preview failed."
};
}
});
app.get<{
Querystring: { skuId?: string };
}>("/api/platforms/jd/live-detail-preview", async (request, reply) => {
try {
const skuId = request.query.skuId?.trim();
if (!skuId) {
reply.code(400);
return { message: "skuId is required." };
}
const preview = await jdLiveService.previewDetail(skuId);
return { preview };
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "JD live detail preview failed."
};
}
});
app.get<{
Querystring: {
query?: string;
commentCount?: string;
maxPages?: string;
mode?: JdSearchMode;
};
}>("/api/platforms/jd/live-keyword-preview", async (request, reply) => {
try {
const query = request.query.query?.trim();
if (!query) {
reply.code(400);
return { message: "query is required." };
}
const commentCount = request.query.commentCount
? Number.parseInt(request.query.commentCount, 10)
: undefined;
const maxPages = request.query.maxPages
? Number.parseInt(request.query.maxPages, 10)
: undefined;
const searchPreview = await jdLiveService.previewSearch(query, request.query.mode);
const rankedCandidates = rankJdCandidatesForKeyword(query, searchPreview.candidates);
const selectedCandidate = rankedCandidates[0];
if (!selectedCandidate) {
reply.code(404);
return {
message:
"JD live search returned no valid item candidates for this keyword. Capture a fresher search session or refine the query."
};
}
const preview = await jdLiveService.previewProduct(selectedCandidate.skuId, {
commentCount,
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
});
return {
preview: {
query,
search: {
source: searchPreview.source,
candidateCount: searchPreview.candidateCount,
selected: selectedCandidate,
alternatives: rankedCandidates.slice(1, 4)
},
product: preview
}
};
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "JD live keyword preview failed."
};
}
});
app.get<{
Querystring: {
skuId?: string;
commentCount?: string;
page?: string;
maxPages?: string;
};
}>("/api/platforms/jd/live-reviews-preview", async (request, reply) => {
try {
const skuId = request.query.skuId?.trim();
if (!skuId) {
reply.code(400);
return { message: "skuId is required." };
}
const commentCount = request.query.commentCount
? Number.parseInt(request.query.commentCount, 10)
: undefined;
const page = request.query.page ? Number.parseInt(request.query.page, 10) : undefined;
const maxPages = request.query.maxPages
? Number.parseInt(request.query.maxPages, 10)
: undefined;
const preview = await jdLiveService.previewReviews(skuId, {
commentCount,
page: Number.isNaN(page ?? Number.NaN) ? undefined : page,
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
});
return { preview };
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "JD live reviews preview failed."
};
}
});
app.get<{
Querystring: {
skuId?: string;
commentCount?: string;
page?: string;
maxPages?: string;
};
}>("/api/platforms/jd/live-product-preview", async (request, reply) => {
try {
const skuId = request.query.skuId?.trim();
if (!skuId) {
reply.code(400);
return { message: "skuId is required." };
}
const commentCount = request.query.commentCount
? Number.parseInt(request.query.commentCount, 10)
: undefined;
const page = request.query.page ? Number.parseInt(request.query.page, 10) : undefined;
const maxPages = request.query.maxPages
? Number.parseInt(request.query.maxPages, 10)
: undefined;
const preview = await jdLiveService.previewProduct(skuId, {
commentCount,
page: Number.isNaN(page ?? Number.NaN) ? undefined : page,
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
});
return { preview };
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "JD live product preview failed."
};
}
});
app.get<{
Querystring: { itemId?: string };
}>("/api/platforms/tmall/live-detail-preview", async (request, reply) => {
try {
const itemId = request.query.itemId?.trim();
if (!itemId) {
reply.code(400);
return { message: "itemId is required." };
}
const preview = await tmallLiveService.previewDetail(itemId);
return { preview };
} catch (error) {
reply.code(isTmallLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "Tmall live detail preview failed."
};
}
});
app.get<{
Querystring: { query?: string };
}>("/api/platforms/tmall/live-search-preview", async (request, reply) => {
try {
const query = request.query.query?.trim();
if (!query) {
reply.code(400);
return { message: "query is required." };
}
const preview = await tmallLiveService.previewSearch(query);
return { preview };
} catch (error) {
reply.code(isTmallLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "Tmall live search preview failed."
};
}
});
app.get<{
Querystring: {
itemId?: string;
commentCount?: string;
page?: string;
maxPages?: string;
};
}>("/api/platforms/tmall/live-reviews-preview", async (request, reply) => {
try {
const itemId = request.query.itemId?.trim();
if (!itemId) {
reply.code(400);
return { message: "itemId is required." };
}
const commentCount = request.query.commentCount
? Number.parseInt(request.query.commentCount, 10)
: undefined;
const page = request.query.page ? Number.parseInt(request.query.page, 10) : undefined;
const maxPages = request.query.maxPages
? Number.parseInt(request.query.maxPages, 10)
: undefined;
const preview = await tmallLiveService.previewReviews(itemId, {
commentCount,
page: Number.isNaN(page ?? Number.NaN) ? undefined : page,
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
});
return { preview };
} catch (error) {
reply.code(isTmallLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "Tmall live reviews preview failed."
};
}
});
app.get<{
Querystring: {
itemId?: string;
commentCount?: string;
page?: string;
maxPages?: string;
};
}>("/api/platforms/tmall/live-product-preview", async (request, reply) => {
try {
const itemId = request.query.itemId?.trim();
if (!itemId) {
reply.code(400);
return { message: "itemId is required." };
}
const commentCount = request.query.commentCount
? Number.parseInt(request.query.commentCount, 10)
: undefined;
const page = request.query.page ? Number.parseInt(request.query.page, 10) : undefined;
const maxPages = request.query.maxPages
? Number.parseInt(request.query.maxPages, 10)
: undefined;
const preview = await tmallLiveService.previewProduct(itemId, {
commentCount,
page: Number.isNaN(page ?? Number.NaN) ? undefined : page,
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
});
return { preview };
} catch (error) {
reply.code(isTmallLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "Tmall live product preview failed."
};
}
});
app.post<{
Body?: {
dryRun?: boolean;
asOf?: string;
rawRetentionDays?: number;
reportRetentionDays?: number;
};
}>("/api/retention/cleanup", async (request, reply) => {
try {
const cleanup = store.runRetentionCleanup(request.body ?? {});
return { cleanup };
} catch (error) {
reply.code(400);
return {
message:
error instanceof Error ? error.message : "Invalid retention cleanup request."
};
}
});
app.get("/api/history", async () => ({
tasks: store.listHistory()
}));
app.get("/api/observability/overview", async () => ({
overview: store.getObservabilityOverview()
}));
app.get<{
Params: { taskId: string };
}>("/api/tasks/:taskId/events", async (request, reply) => {
const task = store.getTask(request.params.taskId);
if (!task) {
reply.code(404);
return { message: "Task not found." };
}
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": request.headers.origin ?? "*",
Vary: "Origin"
});
const writeSnapshot = (nextTask = task) => {
reply.raw.write(`event: task.snapshot\n`);
reply.raw.write(`data: ${JSON.stringify({ task: nextTask })}\n\n`);
};
const unsubscribe = store.subscribeToTask(request.params.taskId, writeSnapshot);
const heartbeat = setInterval(() => {
reply.raw.write(`: keep-alive\n\n`);
}, 15000);
writeSnapshot(task);
request.raw.on("close", () => {
clearInterval(heartbeat);
unsubscribe();
});
});
return app;
}