feat: 接入运维扫码登录与任务实时执行链路

This commit is contained in:
renzhiye 2026-04-07 19:53:40 +08:00
parent 8c4d057202
commit 4ea9eaf4a6
20 changed files with 3339 additions and 189 deletions

View File

@ -14,6 +14,7 @@
"@cross-ai/domain": "file:../../packages/domain",
"@cross-ai/report-schema": "file:../../packages/report-schema",
"@fastify/cors": "^11.2.0",
"fastify": "^5.8.4"
"fastify": "^5.8.4",
"playwright-core": "^1.59.1"
}
}

View File

@ -0,0 +1,214 @@
import type { BrowserContext, Page, Request } from "playwright-core";
import { describe, expect, it, vi } from "vitest";
import type {
JdLiveSessionInput,
JdSessionManager,
JdSessionManagerConfigInput,
JdSessionManagerRunResult,
JdSessionManagerState
} from "../platforms/jd/types";
import { JdOpsQrLoginService, takeLocatorScreenshotDataUrl } from "./qr-login";
const DEFAULT_TEST_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
type FakeResources = {
context: Pick<BrowserContext, "cookies" | "pages" | "close" | "storageState">;
page: Page;
};
function createSessionManagerStub() {
const importManualSession = vi.fn<
(input: JdLiveSessionInput, source?: string) => Promise<JdSessionManagerState>
>(async () => ({ status: "healthy" } as JdSessionManagerState));
const runHealthCheck = vi.fn<(trigger?: string) => Promise<JdSessionManagerRunResult>>(
async () =>
({
recovered: false,
state: {
status: "healthy"
}
}) as JdSessionManagerRunResult
);
const stub: JdSessionManager = {
getState(): JdSessionManagerState {
return { status: "idle" } as JdSessionManagerState;
},
configure(_input: JdSessionManagerConfigInput): JdSessionManagerState {
return { status: "idle" } as JdSessionManagerState;
},
clearConfig(): JdSessionManagerState {
return { status: "idle" } as JdSessionManagerState;
},
importManualSession,
clearManagedSession(): JdSessionManagerState {
return { status: "idle" } as JdSessionManagerState;
},
runHealthCheck,
async runAutoRecovery() {
return {
recovered: false,
state: { status: "idle" } as JdSessionManagerState
};
},
async handleLiveFailure() {
return false;
},
shutdown() {
return;
}
};
return {
stub,
importManualSession,
runHealthCheck
};
}
class TestableJdOpsQrLoginService extends JdOpsQrLoginService {
setTestResources(resources: FakeResources) {
this.setResources(resources as unknown as {
browser: never;
context: BrowserContext;
page: Page;
});
}
setCapturedSession(session: Record<string, unknown>) {
Object.assign(
(this as unknown as { latestCapturedSession: Record<string, unknown> }).latestCapturedSession,
session
);
}
}
describe("JdOpsQrLoginService", () => {
it("skips a detached QR locator and captures the next visible selector", async () => {
const staleLocator = {
first: vi.fn(function (this: typeof staleLocator) {
return this;
}),
count: vi.fn(async () => 1),
isVisible: vi.fn(async () => true),
screenshot: vi.fn(async () => {
throw new Error("Element is not attached to the DOM");
})
};
const liveScreenshot = Uint8Array.from([1, 2, 3]);
const liveLocator = {
first: vi.fn(function (this: typeof liveLocator) {
return this;
}),
count: vi.fn(async () => 1),
isVisible: vi.fn(async () => true),
screenshot: vi.fn(async () => liveScreenshot)
};
const page = {
locator: vi.fn((selector: string) => {
return selector === ".stale-qr" ? staleLocator : liveLocator;
})
} as unknown as Page;
await expect(takeLocatorScreenshotDataUrl(page, [".stale-qr", ".live-qr"])).resolves.toBe(
"data:image/png;base64,AQID"
);
expect(staleLocator.screenshot).toHaveBeenCalledTimes(1);
expect(liveLocator.screenshot).toHaveBeenCalledTimes(1);
});
it("waits for a fresh search request during manual recovery and reimports captured headers", async () => {
const { stub, importManualSession, runHealthCheck } = createSessionManagerStub();
const service = new TestableJdOpsQrLoginService(stub);
let currentUrl =
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=old";
let resolveRequest: ((request: Request) => void) | undefined;
const request = {
url: () =>
"https://api.m.jd.com/api?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22%E5%A4%A7%E7%96%86pocket3%22%7D&keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm",
allHeaders: async () => ({
":authority": "api.m.jd.com",
referer:
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm",
"accept-language": "zh-CN,zh;q=0.9",
"sec-fetch-site": "same-site"
})
} as unknown as Request;
const page = {
url: () => currentUrl,
isClosed: () => false,
waitForRequest: vi.fn(async () => {
return await new Promise<Request>((resolve) => {
resolveRequest = resolve;
});
}),
reload: vi.fn(async () => {
currentUrl =
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm";
resolveRequest?.(request);
}),
goto: vi.fn(async () => undefined),
waitForLoadState: vi.fn(async () => undefined),
waitForTimeout: vi.fn(async () => undefined),
evaluate: vi.fn(async () => DEFAULT_TEST_USER_AGENT),
close: vi.fn(async () => undefined)
} as unknown as Page;
const context = {
cookies: vi.fn(async () => [
{
name: "thor",
value: "masked",
domain: ".jd.com"
}
]),
pages: vi.fn(() => [page]),
close: vi.fn(async () => undefined),
storageState: vi.fn(async () => ({
cookies: [],
origins: []
}))
} as unknown as Pick<BrowserContext, "cookies" | "pages" | "close" | "storageState">;
service.setTestResources({
context,
page
});
service.setCapturedSession({
targetSkuId: "100068388533",
targetSearchQuery: "大疆pocket3",
targetProductUrl: "https://item.jd.com/100068388533.html",
targetSearchUrl:
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=old",
detailTemplateUrl:
"https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22%7D",
reviewsTemplateUrl:
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533,%22page%22:1,%22commentNum%22:5%7D",
searchCapturedAt: 1
});
const state = await service.resumeManualRecovery();
expect(page.reload).toHaveBeenCalledTimes(1);
expect(importManualSession).toHaveBeenCalledWith(
expect.objectContaining({
cookieHeader: "thor=masked",
userAgent: DEFAULT_TEST_USER_AGENT,
searchApiTemplateUrl: request.url(),
searchReferer:
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm",
searchRequestHeaders: {
"accept-language": "zh-CN,zh;q=0.9",
"sec-fetch-site": "same-site"
}
}),
"ops-qr-login-manual-recovery"
);
expect(runHealthCheck).toHaveBeenCalledWith("ops-qr-login-manual-recovery");
expect(state.status).toBe("completed");
expect(state.sessionImported).toBe(true);
});
});

1243
apps/api/src/ops/qr-login.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -60,6 +60,65 @@ describe("JdLiveSessionService", () => {
});
});
it("reuses imported JD search API templates and rewrites query parameters", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
buildResponse({
data: {
wareList: [
{
skuId: "100068388533",
imageurl: "//img14.360buyimg.com/n7/jfs/t1/example.jpg",
wname: "大疆 Pocket 3 全能套装",
good: "99%",
jdPrice: "5199.00",
shopName: "京东自营"
}
]
}
})
);
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
service.importSession({
cookieHeader: "thor=masked;",
searchApiTemplateUrl:
"https://api.m.jd.com/api?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22iPhone%2015%22,%22wq%22:%22iPhone%2015%22,%22page%22:1%7D&keyword=iPhone%2015&enc=utf-8&suggest=2.def.0.test&wq=iPhone%2015&pvid=test-pvid&spmTag=test-spm",
searchRequestHeaders: {
":authority": "api.m.jd.com",
"accept-language": "zh-CN,zh;q=0.9",
"sec-fetch-site": "same-site",
cookie: "should-be-ignored"
}
});
const preview = await service.previewSearch("大疆pocket3");
expect(preview.source).toBe("api");
expect(preview.candidateCount).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
const requestUrl = fetchMock.mock.calls[0]?.[0];
const requestInit = fetchMock.mock.calls[0]?.[1];
if (!requestUrl || !requestInit) {
throw new Error("Expected JD search preview to call fetch with url and init.");
}
const parsedUrl = new URL(requestUrl);
const decodedBody = decodeURIComponent(parsedUrl.searchParams.get("body") ?? "");
expect(parsedUrl.searchParams.get("keyword")).toBe("大疆pocket3");
expect(parsedUrl.searchParams.get("wq")).toBe("大疆pocket3");
expect(parsedUrl.searchParams.get("pvid")).toBe("test-pvid");
expect(decodedBody).toContain(`"keyword":"${parsedUrl.searchParams.get("keyword")}"`);
expect(decodedBody).toContain(`"wq":"${parsedUrl.searchParams.get("wq")}"`);
expect(requestInit.headers).toMatchObject({
Referer: expect.stringContaining("keyword=%E5%A4%A7%E7%96%86pocket3"),
"User-Agent": expect.any(String),
"accept-language": "zh-CN,zh;q=0.9",
"sec-fetch-site": "same-site"
});
expect(requestInit.headers).not.toHaveProperty(":authority");
});
it("paginates and deduplicates JD reviews across multiple pages", async () => {
const fetchMock = vi
.fn<(input: string, init?: RequestInit) => Promise<Response>>()
@ -269,6 +328,155 @@ describe("JdLiveSessionService", () => {
});
});
it("falls back to browser-backed search preview when Node replay is risk-blocked", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
new Response("blocked", { status: 403 })
);
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
const browserPreviewSearch = vi.fn(async () => ({
query: "大疆pocket3",
source: "html" as const,
candidateCount: 1,
candidates: [
{
candidateId: "jd-100068388533",
platform: "jd" as const,
title: "大疆 Pocket 3 全能套装",
price: 5199,
priceLabel: "CNY 5199",
storeName: "京东自营",
productUrl: "https://item.jd.com/100068388533.html",
imageUrl: "https://img14.360buyimg.com/n7/jfs/t1/example.jpg",
salesHint: "sold 500+",
specLabel: "标准版",
highlights: ["browser fallback"]
}
]
}));
service.setBrowserPreviewProvider({
previewSearch: browserPreviewSearch,
previewDetail: vi.fn(),
previewReviews: vi.fn()
});
service.importSession({
cookieHeader: "thor=masked;",
searchApiTemplateUrl:
"https://api.m.jd.com/api?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22iPhone%2015%22,%22wq%22:%22iPhone%2015%22,%22page%22:1%7D&keyword=iPhone%2015&enc=utf-8"
});
const preview = await service.previewSearch("大疆pocket3");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(browserPreviewSearch).toHaveBeenCalledWith("大疆pocket3", "api");
expect(preview).toMatchObject({
source: "html",
candidateCount: 1
});
});
it("falls back to browser-backed detail preview when Node replay is risk-blocked", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
new Response("blocked", { status: 403 })
);
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
const browserPreviewDetail = vi.fn(async () => ({
skuId: "100068388535",
source: "api" as const,
detail: {
skuId: "100068388535",
title: "Apple iPhone 15",
price: "4398.00",
originalPrice: "4599.00",
estimatedPrice: "4398.00",
shopName: "JD Self Operated",
vendorId: null,
categoryPath: [],
stockState: "in stock",
mainImage: null,
averageScore: "4.9"
}
}));
service.setBrowserPreviewProvider({
previewSearch: vi.fn(),
previewDetail: browserPreviewDetail,
previewReviews: vi.fn()
});
service.importSession({
cookieHeader: "thor=masked;",
detailTemplateUrl:
"https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22,%22area%22:%222_2813_61125_0%22%7D"
});
const preview = await service.previewDetail("100068388535");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(browserPreviewDetail).toHaveBeenCalledWith("100068388535");
expect(preview.detail.title).toBe("Apple iPhone 15");
});
it("falls back to browser-backed reviews preview when Node replay is risk-blocked", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
new Response("blocked", { status: 403 })
);
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
const browserPreviewReviews = vi.fn(async () => ({
skuId: "100068388535",
source: "api" as const,
pagination: {
requestedPage: 1,
requestedCommentCount: 2,
maxPages: 1,
pagesFetched: 1
},
reviews: {
skuId: "100068388535",
total: "1000",
goodRate: "96%",
pictureCount: "120",
tags: [],
comments: [
{
id: "comment-1",
content: "great",
score: "5",
creationTime: null,
userLevelName: null
}
]
}
}));
service.setBrowserPreviewProvider({
previewSearch: vi.fn(),
previewDetail: vi.fn(),
previewReviews: browserPreviewReviews
});
service.importSession({
cookieHeader: "thor=masked;",
reviewsTemplateUrl:
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22shopType%22:%220%22,%22sku%22:100068388533,%22commentNum%22:2,%22page%22:1,%22source%22:%22pc%22%7D"
});
const preview = await service.previewReviews("100068388535", {
commentCount: 2,
page: 1,
maxPages: 1
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(browserPreviewReviews).toHaveBeenCalledWith("100068388535", {
commentCount: 2,
page: 1,
maxPages: 1
});
expect(preview.reviews.comments).toHaveLength(1);
});
it("rejects a detail template that still resolves to another sku", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
buildResponse({

View File

@ -5,6 +5,7 @@ import {
parseJdSearchHtml
} from "./parsers";
import type {
JdBrowserPreviewProvider,
JdDetailPreviewResult,
JdLiveService,
JdProductPreviewResult,
@ -38,6 +39,7 @@ const REVIEW_PAGE_BODY_KEYS = [
"commentPage"
];
const REVIEW_PAGE_SIZE_BODY_KEYS = ["commentNum", "pageSize", "pageLimit"];
const SEARCH_QUERY_BODY_KEYS = ["keyword", "wq", "word", "keyWord", "query"];
const SEARCH_FUNCTION_ID = "pc_search_searchWare";
const DETAIL_FUNCTION_ID = "pc_detailpage_wareBusiness";
const REVIEWS_FUNCTION_ID = "getLegoWareDetailComment";
@ -81,6 +83,9 @@ type StoredJdLiveSession = {
reviewsTemplateUrl?: string | undefined;
searchReferer?: string | undefined;
detailReferer?: string | undefined;
searchRequestHeaders?: Record<string, string> | undefined;
detailRequestHeaders?: Record<string, string> | undefined;
reviewsRequestHeaders?: Record<string, string> | undefined;
};
class JdLiveError extends Error {
@ -158,20 +163,52 @@ function buildTemplateSummary(templateUrl: string | undefined): JdTemplateSummar
};
}
function templateMatchesQuery(
templateUrl: string | undefined,
query: string
): boolean {
if (!templateUrl) {
return false;
function normalizeCapturedHeaders(
input: Record<string, string> | undefined
): Record<string, string> | undefined {
if (!input) {
return undefined;
}
try {
const templateKeyword = new URL(templateUrl).searchParams.get("keyword");
return Boolean(templateKeyword && templateKeyword === query);
} catch {
return false;
const isValidReplayHeaderName = (key: string) => /^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(key);
const entries = Object.entries(input)
.map(([key, value]) => [key.trim().toLowerCase(), value.trim()] as const)
.filter(([key, value]) => {
return (
key.length > 0 &&
value.length > 0 &&
!key.startsWith(":") &&
isValidReplayHeaderName(key) &&
key !== "cookie" &&
key !== "referer" &&
key !== "host" &&
key !== "content-length" &&
key !== "connection" &&
key !== "accept-encoding"
);
});
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function buildReplayHeaders(
capturedHeaders: Record<string, string> | undefined,
required: {
accept: string;
cookie: string;
referer: string;
userAgent: string;
}
): Record<string, string> {
const normalizedCapturedHeaders = normalizeCapturedHeaders(capturedHeaders);
return {
...(normalizedCapturedHeaders ?? {}),
...(normalizedCapturedHeaders?.accept ? {} : { Accept: required.accept }),
Cookie: required.cookie,
Referer: required.referer,
"User-Agent": required.userAgent
};
}
function coerceBodyValue(existing: unknown, nextValue: number | string): number | string {
@ -316,6 +353,78 @@ function buildDetailRequestUrl(templateUrl: string, skuId: string): string {
}));
}
function buildSearchApiRequestUrl(templateUrl: string, query: string): string {
let requestUrl = new URL(templateUrl);
requestUrl.searchParams.set("keyword", query);
if (requestUrl.searchParams.has("wq")) {
requestUrl.searchParams.set("wq", query);
}
if (!requestUrl.searchParams.has("enc")) {
requestUrl.searchParams.set("enc", "utf-8");
}
if (readQueryBody(requestUrl)) {
requestUrl = new URL(
withUpdatedQueryBody(requestUrl, (currentBody) => {
const nextBody: Record<string, unknown> = {
...currentBody
};
let updated = false;
for (const key of SEARCH_QUERY_BODY_KEYS) {
if (!(key in currentBody)) {
continue;
}
nextBody[key] = coerceBodyValue(currentBody[key], query);
updated = true;
}
if (!updated) {
nextBody.keyword = query;
}
return nextBody;
})
);
}
return requestUrl.toString();
}
function buildSearchPageUrlFromTemplate(templateUrl: string, query: string): string {
const template = new URL(templateUrl);
const searchUrl = new URL("https://search.jd.com/Search");
for (const [key, value] of template.searchParams.entries()) {
if (
key === "functionId" ||
key === "appid" ||
key === "client" ||
key === "clientVersion" ||
key === "t" ||
key === "loginType" ||
key === "body"
) {
continue;
}
searchUrl.searchParams.set(key, value);
}
searchUrl.searchParams.set("keyword", query);
if (searchUrl.searchParams.has("wq")) {
searchUrl.searchParams.set("wq", query);
}
if (!searchUrl.searchParams.has("enc")) {
searchUrl.searchParams.set("enc", "utf-8");
}
return searchUrl.toString();
}
function buildReviewsRequestUrl(
templateUrl: string,
skuId: string,
@ -472,6 +581,11 @@ export function getJdLiveErrorCode(error: unknown): JdLiveErrorCode | undefined
export class JdLiveSessionService implements JdLiveService {
private session: StoredJdLiveSession | null = readEnvSession();
private browserPreviewProvider: JdBrowserPreviewProvider | null = null;
setBrowserPreviewProvider(provider: JdBrowserPreviewProvider | null): void {
this.browserPreviewProvider = provider;
}
getSessionSummary(): JdLiveSessionSummary {
return {
@ -491,6 +605,9 @@ export class JdLiveSessionService implements JdLiveService {
const reviewsTemplateUrl = input.reviewsTemplateUrl?.trim();
const searchReferer = input.searchReferer?.trim();
const detailReferer = input.detailReferer?.trim();
const searchRequestHeaders = normalizeCapturedHeaders(input.searchRequestHeaders);
const detailRequestHeaders = normalizeCapturedHeaders(input.detailRequestHeaders);
const reviewsRequestHeaders = normalizeCapturedHeaders(input.reviewsRequestHeaders);
validateImportedTemplates({
searchApiTemplateUrl,
@ -506,7 +623,10 @@ export class JdLiveSessionService implements JdLiveService {
...(detailTemplateUrl ? { detailTemplateUrl } : {}),
...(reviewsTemplateUrl ? { reviewsTemplateUrl } : {}),
...(searchReferer ? { searchReferer } : {}),
...(detailReferer ? { detailReferer } : {})
...(detailReferer ? { detailReferer } : {}),
...(searchRequestHeaders ? { searchRequestHeaders } : {}),
...(detailRequestHeaders ? { detailRequestHeaders } : {}),
...(reviewsRequestHeaders ? { reviewsRequestHeaders } : {})
};
return this.getSessionSummary();
@ -526,77 +646,75 @@ export class JdLiveSessionService implements JdLiveService {
throw new JdLiveError("query is required for JD live search preview.", 400, "BAD_REQUEST");
}
const resolvedMode =
mode ??
(templateMatchesQuery(session.searchApiTemplateUrl, normalizedQuery) ? "api" : "html");
const resolvedMode = mode ?? (session.searchApiTemplateUrl ? "api" : "html");
if (resolvedMode === "api") {
if (!session.searchApiTemplateUrl) {
throw new JdLiveError(
"JD search API template is missing. Import a fresh search request URL or use mode=html.",
409,
"TEMPLATE_MISSING"
);
}
const templateUrl = new URL(session.searchApiTemplateUrl);
const templateKeyword = templateUrl.searchParams.get("keyword");
if (templateKeyword && templateKeyword !== normalizedQuery) {
throw new JdLiveError(
`Imported search API template is locked to query "${templateKeyword}". ` +
"Capture a fresh request for the target query or use mode=html.",
409,
"TEMPLATE_QUERY_LOCKED"
try {
if (resolvedMode === "api") {
if (!session.searchApiTemplateUrl) {
throw new JdLiveError(
"JD search API template is missing. Import a fresh search request URL or use mode=html.",
409,
"TEMPLATE_MISSING"
);
}
const requestUrl = buildSearchApiRequestUrl(session.searchApiTemplateUrl, normalizedQuery);
const referer = buildSearchPageUrlFromTemplate(
session.searchApiTemplateUrl,
normalizedQuery
);
const response = await fetchTextOrThrow(
requestUrl,
{
headers: buildReplayHeaders(session.searchRequestHeaders, {
accept: "application/json, text/plain, */*",
cookie: session.cookieHeader,
referer,
userAgent: session.userAgent
})
},
"JD search session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const candidates = parseJdSearchApiResponse(normalizedQuery, { text: response.text });
return {
query: normalizedQuery,
source: "api",
candidateCount: candidates.length,
candidates
};
}
const searchUrl = `https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`;
const response = await fetchTextOrThrow(
session.searchApiTemplateUrl,
searchUrl,
{
headers: {
Accept: "application/json, text/plain, */*",
Cookie: session.cookieHeader,
Referer:
session.searchReferer ??
`https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`,
"User-Agent": session.userAgent
}
headers: buildReplayHeaders(session.searchRequestHeaders, {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
cookie: session.cookieHeader,
referer: session.searchReferer ?? "https://www.jd.com/",
userAgent: session.userAgent
})
},
"JD search session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const candidates = parseJdSearchApiResponse(normalizedQuery, { text: response.text });
const candidates = parseJdSearchHtml(normalizedQuery, response.text);
return {
query: normalizedQuery,
source: "api",
source: "html",
candidateCount: candidates.length,
candidates
};
} catch (error) {
const browserPreview = await this.tryBrowserSearchFallback(error, normalizedQuery, resolvedMode);
if (browserPreview) {
return browserPreview;
}
throw error;
}
const searchUrl = `https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`;
const response = await fetchTextOrThrow(
searchUrl,
{
headers: {
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9",
Cookie: session.cookieHeader,
Referer: session.searchReferer ?? "https://www.jd.com/",
"User-Agent": session.userAgent
}
},
"JD search session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const candidates = parseJdSearchHtml(normalizedQuery, response.text);
return {
query: normalizedQuery,
source: "html",
candidateCount: candidates.length,
candidates
};
}
async previewDetail(skuId: string): Promise<JdDetailPreviewResult> {
@ -616,33 +734,42 @@ export class JdLiveSessionService implements JdLiveService {
const requestUrl = buildDetailRequestUrl(session.detailTemplateUrl, normalizedSkuId);
const response = await fetchTextOrThrow(
requestUrl,
{
headers: {
Accept: "application/json, text/plain, */*",
Cookie: session.cookieHeader,
Referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
"User-Agent": session.userAgent
}
},
"JD detail session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const detail = parseJdDetailApiResponse(normalizedSkuId, { text: response.text });
if (detail.skuId !== normalizedSkuId) {
throw new JdLiveError(
`JD detail template appears bound to another sku (${detail.skuId}). Capture a fresh pc_detailpage_wareBusiness request first.`,
409,
"TEMPLATE_EXPIRED"
try {
const response = await fetchTextOrThrow(
requestUrl,
{
headers: buildReplayHeaders(session.detailRequestHeaders, {
accept: "application/json, text/plain, */*",
cookie: session.cookieHeader,
referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
userAgent: session.userAgent
})
},
"JD detail session appears invalid. Re-login in the browser and re-import the cookie/header."
);
}
return {
skuId: normalizedSkuId,
source: "api",
detail
};
const detail = parseJdDetailApiResponse(normalizedSkuId, { text: response.text });
if (detail.skuId !== normalizedSkuId) {
throw new JdLiveError(
`JD detail template appears bound to another sku (${detail.skuId}). Capture a fresh pc_detailpage_wareBusiness request first.`,
409,
"TEMPLATE_EXPIRED"
);
}
return {
skuId: normalizedSkuId,
source: "api",
detail
};
} catch (error) {
const browserPreview = await this.tryBrowserDetailFallback(error, normalizedSkuId);
if (browserPreview) {
return browserPreview;
}
throw error;
}
}
async previewReviews(
@ -668,64 +795,77 @@ export class JdLiveSessionService implements JdLiveService {
let pageKey: string | undefined;
const seenCommentIds = new Set<string>();
for (let pageOffset = 0; pageOffset < resolvedOptions.maxPages; pageOffset += 1) {
const currentPage = resolvedOptions.page + pageOffset;
const request = buildReviewsRequestUrl(
session.reviewsTemplateUrl,
normalizedSkuId,
resolvedOptions,
currentPage
);
pageKey ??= request.pageKey;
try {
for (let pageOffset = 0; pageOffset < resolvedOptions.maxPages; pageOffset += 1) {
const currentPage = resolvedOptions.page + pageOffset;
const request = buildReviewsRequestUrl(
session.reviewsTemplateUrl,
normalizedSkuId,
resolvedOptions,
currentPage
);
pageKey ??= request.pageKey;
const response = await fetchTextOrThrow(
request.url,
{
headers: {
Accept: "application/json, text/plain, */*",
Cookie: session.cookieHeader,
Referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
"User-Agent": session.userAgent
const response = await fetchTextOrThrow(
request.url,
{
headers: buildReplayHeaders(session.reviewsRequestHeaders, {
accept: "application/json, text/plain, */*",
cookie: session.cookieHeader,
referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
userAgent: session.userAgent
})
},
"JD reviews session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const parsedPage = parseJdReviewsApiResponse(normalizedSkuId, { text: response.text });
reviewPages.push(parsedPage);
let newCommentCount = 0;
for (const comment of parsedPage.comments) {
if (seenCommentIds.has(comment.id)) {
continue;
}
},
"JD reviews session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const parsedPage = parseJdReviewsApiResponse(normalizedSkuId, { text: response.text });
reviewPages.push(parsedPage);
let newCommentCount = 0;
for (const comment of parsedPage.comments) {
if (seenCommentIds.has(comment.id)) {
continue;
seenCommentIds.add(comment.id);
newCommentCount += 1;
}
seenCommentIds.add(comment.id);
newCommentCount += 1;
if (
parsedPage.comments.length === 0 ||
newCommentCount === 0 ||
parsedPage.comments.length < resolvedOptions.commentCount
) {
break;
}
}
if (
parsedPage.comments.length === 0 ||
newCommentCount === 0 ||
parsedPage.comments.length < resolvedOptions.commentCount
) {
break;
const pagination: JdReviewsPaginationSummary = {
requestedPage: resolvedOptions.page,
requestedCommentCount: resolvedOptions.commentCount,
maxPages: resolvedOptions.maxPages,
pagesFetched: reviewPages.length,
...(pageKey ? { pageKey } : {})
};
return {
skuId: normalizedSkuId,
source: "api",
pagination,
reviews: mergeReviewPages(normalizedSkuId, reviewPages)
};
} catch (error) {
const browserPreview = await this.tryBrowserReviewsFallback(
error,
normalizedSkuId,
options
);
if (browserPreview) {
return browserPreview;
}
throw error;
}
const pagination: JdReviewsPaginationSummary = {
requestedPage: resolvedOptions.page,
requestedCommentCount: resolvedOptions.commentCount,
maxPages: resolvedOptions.maxPages,
pagesFetched: reviewPages.length,
...(pageKey ? { pageKey } : {})
};
return {
skuId: normalizedSkuId,
source: "api",
pagination,
reviews: mergeReviewPages(normalizedSkuId, reviewPages)
};
}
async previewProduct(
@ -757,4 +897,47 @@ export class JdLiveSessionService implements JdLiveService {
return this.session;
}
private shouldUseBrowserFallback(error: unknown): boolean {
const code = getJdLiveErrorCode(error);
return Boolean(
this.browserPreviewProvider &&
(code === "RISK_BLOCKED" || code === "NETWORK_ERROR" || code === "HTTP_ERROR")
);
}
private async tryBrowserSearchFallback(
error: unknown,
query: string,
mode: JdSearchMode
): Promise<JdSearchPreviewResult | null> {
if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) {
return null;
}
return this.browserPreviewProvider.previewSearch(query, mode);
}
private async tryBrowserDetailFallback(
error: unknown,
skuId: string
): Promise<JdDetailPreviewResult | null> {
if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) {
return null;
}
return this.browserPreviewProvider.previewDetail(skuId);
}
private async tryBrowserReviewsFallback(
error: unknown,
skuId: string,
options?: number | JdReviewsPreviewOptions
): Promise<JdReviewsPreviewResult | null> {
if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) {
return null;
}
return this.browserPreviewProvider.previewReviews(skuId, options);
}
}

View File

@ -148,4 +148,62 @@ describe("JD parsers", () => {
userLevelName: "PLUS会员"
});
});
it("falls back to lastCommentInfoList when commentInfoList is missing", () => {
const reviews = parseJdReviewsApiResponse("100068388533", {
allCnt: "3",
goodRate: "98%",
lastCommentInfoList: [
{
commentId: "comment-last-1",
commentData: "fallback review",
commentDate: "2026-04-07 19:00:05",
userNickName: "fallback-user"
}
]
});
expect(reviews.comments).toEqual([
expect.objectContaining({
id: "comment-last-1",
content: "fallback review",
creationTime: "2026-04-07 19:00:05",
userLevelName: "fallback-user"
})
]);
});
it("unwraps nested JD review payloads and commentInfo wrappers", () => {
const reviews = parseJdReviewsApiResponse("100068388533", {
code: 0,
data: {
allCnt: "12",
goodRate: "96%",
commentInfoList: [
{
commentInfo: {
commentId: "comment-nested-1",
content: "nested review",
commentScore: 4,
creationTime: "2026-04-07 20:12:33",
userNickName: "nested-user"
}
}
]
}
});
expect(reviews).toMatchObject({
total: "12",
goodRate: "96%"
});
expect(reviews.comments).toEqual([
expect.objectContaining({
id: "comment-nested-1",
content: "nested review",
score: "4",
creationTime: "2026-04-07 20:12:33",
userLevelName: "nested-user"
})
]);
});
});

View File

@ -30,6 +30,66 @@ function unwrapCapturedPayload(input: unknown): unknown {
}
}
function collectNestedPayloads(
root: Record<string, unknown> | null
): Record<string, unknown>[] {
if (!root) {
return [];
}
const queue: Record<string, unknown>[] = [root];
const seen = new Set<Record<string, unknown>>();
const collected: Record<string, unknown>[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (!current || seen.has(current)) {
continue;
}
seen.add(current);
collected.push(current);
for (const nested of [
asRecord(current.data),
asRecord(current.result),
asRecord(current.bizData),
asRecord(current.commentInfo),
asRecord(current.module)
]) {
if (nested && !seen.has(nested)) {
queue.push(nested);
}
}
}
return collected;
}
function hasReviewPayloadMarkers(payload: Record<string, unknown>): boolean {
return (
"commentInfoList" in payload ||
"lastCommentInfoList" in payload ||
"tagStatisticsinfoList" in payload ||
"allCnt" in payload ||
"allCntStr" in payload ||
"goodCnt" in payload ||
"goodRate" in payload ||
"goodRateShow" in payload ||
"pictureCnt" in payload ||
"showPicCnt" in payload
);
}
function resolveJdReviewsPayload(input: unknown): Record<string, unknown> | null {
const root = asRecord(unwrapCapturedPayload(input));
if (!root) {
return null;
}
return collectNestedPayloads(root).find((payload) => hasReviewPayloadMarkers(payload)) ?? root;
}
function extractSpecLabel(title: string): string {
const storageMatch = title.match(/\b\d+(?:GB|TB)\b/i);
if (storageMatch) {
@ -313,11 +373,32 @@ function parseReviewTag(input: unknown): JdReviewTagSnapshot | null {
}
function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null {
const comment = asRecord(input);
const wrapper = asRecord(input);
const comment = asRecord(wrapper?.commentInfo) ?? wrapper;
const content = normalizeInlineText(
firstString(comment?.content, comment?.commentData, comment?.tagCommentContent)
firstString(
comment?.content,
comment?.commentData,
comment?.tagCommentContent,
comment?.commentContent,
comment?.commentText,
comment?.text,
wrapper?.content,
wrapper?.commentData,
wrapper?.tagCommentContent,
wrapper?.commentContent,
wrapper?.commentText,
wrapper?.text
)
);
const id = firstString(
comment?.id,
comment?.commentId,
comment?.commentUuid,
wrapper?.id,
wrapper?.commentId,
wrapper?.commentUuid
);
const id = firstString(comment?.id, comment?.commentId);
if (!content || !id) {
return null;
@ -326,14 +407,37 @@ function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null {
return {
id,
content,
score: firstString(comment?.score, comment?.commentScore),
score: firstString(
comment?.score,
comment?.commentScore,
comment?.scoring,
wrapper?.score,
wrapper?.commentScore,
wrapper?.scoring
),
creationTime: firstString(
comment?.creationTime,
comment?.creationDate,
comment?.commentDate
comment?.commentDate,
comment?.time,
wrapper?.creationTime,
wrapper?.creationDate,
wrapper?.commentDate,
wrapper?.time
),
userLevelName: normalizeInlineText(
firstString(comment?.userLevelName, comment?.userClientShow)
firstString(
comment?.userLevelName,
comment?.userClientShow,
comment?.userLevel,
comment?.userNickName,
comment?.userName,
wrapper?.userLevelName,
wrapper?.userClientShow,
wrapper?.userLevel,
wrapper?.userNickName,
wrapper?.userName
)
)
};
}
@ -342,13 +446,20 @@ export function parseJdReviewsApiResponse(
skuId: string,
input: unknown
): JdProductReviewsSnapshot {
const payload = asRecord(unwrapCapturedPayload(input));
const payload = resolveJdReviewsPayload(input);
const tags = asArray(payload?.tagStatisticsinfoList)
.map((tag) => parseReviewTag(tag))
.filter((tag): tag is JdReviewTagSnapshot => Boolean(tag));
const comments = asArray(payload?.commentInfoList)
.map((comment) => parseReviewComment(comment))
.filter((comment): comment is JdReviewCommentSnapshot => Boolean(comment));
const commentsById = new Map<string, JdReviewCommentSnapshot>();
for (const comment of [
...asArray(payload?.commentInfoList),
...asArray(payload?.lastCommentInfoList)
]) {
const parsedComment = parseReviewComment(comment);
if (parsedComment && !commentsById.has(parsedComment.id)) {
commentsById.set(parsedComment.id, parsedComment);
}
}
return {
skuId,
@ -356,6 +467,6 @@ export function parseJdReviewsApiResponse(
goodRate: firstString(payload?.goodRate, payload?.goodRateShow),
pictureCount: firstString(payload?.pictureCnt, payload?.showPicCnt),
tags,
comments
comments: Array.from(commentsById.values())
};
}

View File

@ -198,7 +198,7 @@ describe("JdSessionManagerService", () => {
vi.unstubAllEnvs();
});
it("accepts manual session imports and marks the manager healthy", async () => {
it("accepts manual session imports and waits for a health check before marking ready", async () => {
const onSessionReady = vi.fn();
const manager = new JdSessionManagerService(createJdLiveServiceStub(), {
onSessionReady
@ -213,8 +213,8 @@ describe("JdSessionManagerService", () => {
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D"
});
expect(onSessionReady).toHaveBeenCalledOnce();
expect(state.status).toBe("healthy");
expect(onSessionReady).not.toHaveBeenCalled();
expect(state.status).toBe("degraded");
expect(state.pendingManualAction).toBe(false);
expect(state.session).toMatchObject({
configured: true,
@ -294,7 +294,7 @@ describe("JdSessionManagerService", () => {
expect(result.recovered).toBe(false);
expect(result.state.status).toBe("healthy");
expect(onSessionReady).toHaveBeenCalledOnce();
expect(previewSearch).toHaveBeenCalledWith("iPhone 15", "html");
expect(previewSearch).toHaveBeenCalledWith("iPhone 15", "api");
expect(previewDetail).toHaveBeenCalledWith("100068388533");
expect(previewReviews).toHaveBeenCalledWith("100068388533", {
commentCount: 1,

View File

@ -276,15 +276,14 @@ export class JdSessionManagerService implements JdSessionManager {
source = "ops-manual"
): Promise<JdSessionManagerState> {
this.liveService.importSession(input);
this.callbacks.onSessionReady?.();
this.state = {
...this.state,
status: "healthy",
status: "degraded",
pendingManualAction: false,
note: `京东会话已通过 ${source} 更新,等待下一轮健康检查。`,
publicNote: "京东会话由运维后台维护,当前可用。",
lastRecoveredAt: nowIso(),
lastHealthyAt: nowIso(),
note: `JD session imported via ${source}; waiting for health check.`,
publicNote: "JD session was updated and is being verified.",
lastRecoveredAt: undefined,
lastHealthyAt: undefined,
lastFailureCode: undefined,
lastFailureMessage: undefined,
session: this.liveService.getSessionSummary()
@ -556,7 +555,10 @@ export class JdSessionManagerService implements JdSessionManager {
throw new Error("京东会话缺少可校验的详情/评论模板。");
}
await this.liveService.previewSearch(this.config.heartbeatQuery, "html");
await this.liveService.previewSearch(
this.config.heartbeatQuery,
summary.searchApiTemplate.available ? "api" : "html"
);
await this.liveService.previewDetail(detailSkuId);
await this.liveService.previewReviews(reviewsSkuId, {
commentCount: 1,

View File

@ -15,6 +15,9 @@ export interface JdLiveSessionInput {
reviewsTemplateUrl?: string | undefined;
searchReferer?: string | undefined;
detailReferer?: string | undefined;
searchRequestHeaders?: Record<string, string> | undefined;
detailRequestHeaders?: Record<string, string> | undefined;
reviewsRequestHeaders?: Record<string, string> | undefined;
}
export interface JdLiveSessionSummary {
@ -157,6 +160,15 @@ export interface JdProductPreviewResult {
reviews: JdProductReviewsSnapshot;
}
export interface JdBrowserPreviewProvider {
previewSearch(query: string, mode?: JdSearchMode): Promise<JdSearchPreviewResult>;
previewDetail(skuId: string): Promise<JdDetailPreviewResult>;
previewReviews(
skuId: string,
options?: number | JdReviewsPreviewOptions
): Promise<JdReviewsPreviewResult>;
}
export interface JdLiveService {
getSessionSummary(): JdLiveSessionSummary;
importSession(input: JdLiveSessionInput): JdLiveSessionSummary;

View File

@ -85,9 +85,18 @@ function createJdLiveServiceStub(overrides: Partial<JdLiveService> = {}): JdLive
importedAt: "2026-04-03T10:00:00.000Z",
hasCookie: true,
userAgent: input.userAgent ?? "stub-user-agent",
searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) },
detailTemplate: { available: Boolean(input.detailTemplateUrl) },
reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) }
searchApiTemplate: {
available: Boolean(input.searchApiTemplateUrl),
skuId: input.searchApiTemplateUrl ? "100068388533" : undefined
},
detailTemplate: {
available: Boolean(input.detailTemplateUrl),
skuId: input.detailTemplateUrl ? "100068388533" : undefined
},
reviewsTemplate: {
available: Boolean(input.reviewsTemplateUrl),
skuId: input.reviewsTemplateUrl ? "100068388533" : undefined
}
};
return summary;
},

View File

@ -36,9 +36,18 @@ function createJdLiveServiceStub(
importedAt: "2026-04-02T12:00:00.000Z",
hasCookie: true,
userAgent: input.userAgent ?? "stub-user-agent",
searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) },
detailTemplate: { available: Boolean(input.detailTemplateUrl) },
reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) }
searchApiTemplate: {
available: Boolean(input.searchApiTemplateUrl),
skuId: input.searchApiTemplateUrl ? "100068388533" : undefined
},
detailTemplate: {
available: Boolean(input.detailTemplateUrl),
skuId: input.detailTemplateUrl ? "100068388533" : undefined
},
reviewsTemplate: {
available: Boolean(input.reviewsTemplateUrl),
skuId: input.reviewsTemplateUrl ? "100068388533" : undefined
}
};
return summary;
},

View File

@ -12,6 +12,7 @@ import type {
JdReviewsPreviewResult,
JdSearchPreviewResult
} from "./platforms/jd/types";
import type { OpsQrLoginController, OpsQrLoginState } from "./ops/qr-login";
import { createServer } from "./server";
const DAY_MS = 24 * 60 * 60 * 1000;
@ -63,6 +64,31 @@ async function createCompletedTask(app: ReturnType<typeof createServer>, query:
return confirmResponse.json().task;
}
async function waitForTask(
app: ReturnType<typeof createServer>,
taskId: string,
predicate: (task: Awaited<ReturnType<typeof createTask>>) => boolean,
timeoutMs = 1000
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const response = await app.inject({
method: "GET",
url: `/api/tasks/${taskId}`
});
const task = response.json().task as Awaited<ReturnType<typeof createTask>>;
if (predicate(task)) {
return task;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Task ${taskId} did not reach the expected state within ${timeoutMs}ms.`);
}
function backdateStoredTask(storagePath: string, taskId: string, ageDays: number) {
const timestamp = new Date(Date.now() - ageDays * DAY_MS).toISOString();
const snapshot = JSON.parse(readFileSync(storagePath, "utf8")) as {
@ -168,9 +194,18 @@ function createJdLiveServiceStub(
importedAt: "2026-04-02T12:00:00.000Z",
hasCookie: true,
userAgent: input.userAgent ?? "stub-user-agent",
searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) },
detailTemplate: { available: Boolean(input.detailTemplateUrl) },
reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) }
searchApiTemplate: {
available: Boolean(input.searchApiTemplateUrl),
skuId: input.searchApiTemplateUrl ? "100068388533" : undefined
},
detailTemplate: {
available: Boolean(input.detailTemplateUrl),
skuId: input.detailTemplateUrl ? "100068388533" : undefined
},
reviewsTemplate: {
available: Boolean(input.reviewsTemplateUrl),
skuId: input.reviewsTemplateUrl ? "100068388533" : undefined
}
};
return summary;
},
@ -305,6 +340,57 @@ function createJdLiveServiceStub(
};
}
function createQrLoginStub(platform: "jd" | "tmall"): OpsQrLoginController {
let state: OpsQrLoginState = {
platform,
status: "idle",
note: "尚未启动扫码登录。",
sessionImported: false
};
return {
getState() {
return state;
},
async start() {
state = {
...state,
status: "waiting_for_scan",
note: "二维码已生成,请扫码。",
targetId: platform === "jd" ? "100068388533" : "934454505228",
qrImageDataUrl: "data:image/png;base64,stub",
startedAt: "2026-04-07T12:00:00.000Z",
updatedAt: "2026-04-07T12:00:00.000Z"
};
return state;
},
async cancel() {
state = {
...state,
status: "cancelled",
note: "扫码登录已取消。",
completedAt: "2026-04-07T12:10:00.000Z",
updatedAt: "2026-04-07T12:10:00.000Z"
};
return state;
},
async resumeManualRecovery() {
state = {
...state,
status: "completed",
note: "人工恢复完成。",
completedAt: "2026-04-07T12:11:00.000Z",
updatedAt: "2026-04-07T12:11:00.000Z",
sessionImported: true
};
return state;
},
async shutdown() {
return;
}
};
}
describe("API server", () => {
it("returns platform readiness with jd blocked by default", async () => {
const app = createServer();
@ -331,6 +417,79 @@ describe("API server", () => {
await app.close();
});
it("exposes the JD qr-login state and can start the flow", async () => {
const app = createServer({
jdQrLoginService: createQrLoginStub("jd")
});
await app.ready();
const initialResponse = await app.inject({
method: "GET",
url: "/api/ops/jd/session-manager/qr-login"
});
expect(initialResponse.statusCode).toBe(200);
expect(initialResponse.json().qrLogin).toMatchObject({
platform: "jd",
status: "idle"
});
const startResponse = await app.inject({
method: "POST",
url: "/api/ops/jd/session-manager/qr-login/start"
});
expect(startResponse.statusCode).toBe(200);
expect(startResponse.json().qrLogin).toMatchObject({
platform: "jd",
status: "waiting_for_scan",
targetId: "100068388533"
});
await app.close();
});
it("can cancel the Tmall qr-login flow", async () => {
const qrLoginService = createQrLoginStub("tmall");
await qrLoginService.start();
const app = createServer({
tmallQrLoginService: qrLoginService
});
await app.ready();
const cancelResponse = await app.inject({
method: "POST",
url: "/api/ops/tmall/session-manager/qr-login/cancel"
});
expect(cancelResponse.statusCode).toBe(200);
expect(cancelResponse.json().qrLogin).toMatchObject({
platform: "tmall",
status: "cancelled"
});
await app.close();
});
it("can resume the JD qr-login manual recovery flow", async () => {
const qrLoginService = createQrLoginStub("jd");
await qrLoginService.start();
const app = createServer({
jdQrLoginService: qrLoginService
});
await app.ready();
const resumeResponse = await app.inject({
method: "POST",
url: "/api/ops/jd/session-manager/qr-login/resume"
});
expect(resumeResponse.statusCode).toBe(200);
expect(resumeResponse.json().qrLogin).toMatchObject({
platform: "jd",
status: "completed",
sessionImported: true
});
await app.close();
});
it("exposes session state with a 24-hour expiry window after preparation", async () => {
const app = createServer();
await app.ready();
@ -404,6 +563,47 @@ describe("API server", () => {
await app.close();
});
it("runs confirmed tasks in the background when the execution mode is background", async () => {
const app = createServer({ executionMode: "background" });
await app.ready();
const createdTask = await createTask(app, "Nintendo Switch 2");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const firstCandidateId = candidatesResponse.json().candidates.tmall[0].candidateId;
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "tmall",
candidateIds: [firstCandidateId]
}
]
}
});
expect(confirmResponse.statusCode).toBe(200);
expect(confirmResponse.json().task.taskStatus).toBe("Running");
expect(confirmResponse.json().task.taskStage).toBe("session_check");
expect(confirmResponse.json().task.defaultReportVersion).toBeUndefined();
const completedTask = await waitForTask(
app,
createdTask.taskId,
(task) => task.taskStatus === "Completed"
);
expect(completedTask.defaultReportVersion).toBe(1);
expect(completedTask.reportVersions).toEqual([1]);
await app.close();
});
it("records strategy attempts and observability metrics for platform search", async () => {
const app = createServer();
await app.ready();
@ -1239,7 +1439,6 @@ describe("API server", () => {
method: "GET",
url: `/api/tasks/${createdTask.taskId}/audit`
});
expect(auditResponse.statusCode).toBe(200);
expect(auditResponse.json().audit).toEqual(
expect.arrayContaining([
@ -1277,6 +1476,61 @@ describe("API server", () => {
await app.close();
});
it("auto-resumes JD SearchBlocked tasks after the managed session passes health check", async () => {
const app = createServer({
jdLiveService: createJdLiveServiceStub()
});
await app.ready();
const createdTask = await createTask(app, "iPhone 15 Pro");
expect(
createdTask.platformRuns.find((run: { platform: string }) => run.platform === "jd")
).toMatchObject({
platform: "jd",
status: "SearchBlocked"
});
const importResponse = await app.inject({
method: "POST",
url: "/api/ops/jd/session-manager/session",
payload: {
cookieHeader: "thor=masked; pin=masked;",
detailTemplateUrl:
"https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22%7D",
reviewsTemplateUrl:
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D"
}
});
expect(importResponse.statusCode).toBe(200);
const recoveredTask = await waitForTask(
app,
createdTask.taskId,
(task) =>
task.platformRuns.some(
(run: { platform: string; status: string }) =>
run.platform === "jd" && run.status === "AwaitingSelection"
)
);
expect(
recoveredTask.platformRuns.find((run: { platform: string }) => run.platform === "jd")
).toMatchObject({
platform: "jd",
status: "AwaitingSelection"
});
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
expect(candidatesResponse.json().candidates.jd).toHaveLength(1);
await app.close();
});
it("publishes a new report version when a blocked platform recovers successfully", async () => {
const app = createServer();
await app.ready();

View File

@ -16,6 +16,11 @@ import type {
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 {
@ -30,8 +35,11 @@ export function createServer(
storagePath?: string;
jdLiveService?: JdLiveService;
jdSessionManager?: JdSessionManager;
jdQrLoginService?: OpsQrLoginController;
tmallLiveService?: TmallLiveService;
tmallSessionManager?: TmallSessionManager;
tmallQrLoginService?: OpsQrLoginController;
executionMode?: "synchronous" | "background";
} = {}
) {
const app = Fastify({ logger: false });
@ -43,12 +51,17 @@ export function createServer(
(process.env.NODE_ENV === "test"
? undefined
: resolve(process.cwd(), ".data", "task-store.json"));
const store = new InMemoryTaskStore({ storagePath, jdLiveService, tmallLiveService });
const store = new InMemoryTaskStore({
storagePath,
jdLiveService,
tmallLiveService,
executionMode: options.executionMode
});
const jdSessionManager =
options.jdSessionManager ??
new JdSessionManagerService(jdLiveService, {
onSessionReady: () => {
store.preparePlatform("jd");
store.notifyManagedSessionReady("jd");
},
onSessionUnavailable: () => {
store.clearPlatformSession("jd");
@ -64,11 +77,27 @@ export function createServer(
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.preparePlatform("jd");
store.notifyManagedSessionReady("jd");
}
if (tmallLiveService.getSessionSummary().configured && !store.getSession("tmall").ready) {
store.preparePlatform("tmall");
@ -77,6 +106,8 @@ export function createServer(
app.addHook("onClose", async () => {
jdSessionManager.shutdown();
tmallSessionManager.shutdown();
await jdQrLoginService.shutdown();
await tmallQrLoginService.shutdown();
});
app.register(cors, { origin: true });
@ -211,9 +242,13 @@ export function createServer(
};
}>("/api/ops/jd/session-manager/session", async (request, reply) => {
try {
const manager = await jdSessionManager.importManualSession(request.body, "ops-manual");
await jdSessionManager.importManualSession(request.body, "ops-manual");
const result = await jdSessionManager.runHealthCheck("ops-manual");
reply.code(200);
return { manager };
return {
manager: result.state,
recovered: result.recovered
};
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 400);
return {
@ -226,6 +261,42 @@ export function createServer(
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()
}));
@ -290,6 +361,29 @@ export function createServer(
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()
}));
@ -307,6 +401,7 @@ export function createServer(
}>("/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) {
@ -806,15 +901,28 @@ export function createServer(
return { message: "Task not found." };
}
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive"
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();
});
reply.raw.write(`event: task.snapshot\n`);
reply.raw.write(`data: ${JSON.stringify(task)}\n\n`);
reply.raw.end();
return reply;
});
return app;

View File

@ -282,12 +282,15 @@ function isReportableTaskStatus(
return status === "Completed" || status === "PartialCompleted";
}
type TaskExecutionMode = "synchronous" | "background";
type InMemoryTaskStoreOptions = {
storagePath?: string | undefined;
jdLiveService?: JdLiveService | undefined;
jdSessionManager?: JdSessionManager | undefined;
tmallLiveService?: TmallLiveService | undefined;
tmallSessionManager?: TmallSessionManager | undefined;
executionMode?: TaskExecutionMode | undefined;
};
type TaskExecutionArtifact = {
@ -424,11 +427,18 @@ export class InMemoryTaskStore {
Partial<Record<PlatformId, MockExecutionScenario>>
>();
private readonly executionArtifacts = new Map<string, TaskExecutionArtifact[]>();
private readonly taskSubscribers = new Map<
string,
Set<(task: TaskRecord) => void>
>();
private readonly pendingExecutions = new Map<string, Promise<void>>();
private readonly pendingManagedSessionRetries = new Set<string>();
private readonly storagePath: string | undefined;
private readonly jdLiveService: JdLiveService | undefined;
private jdSessionManager: JdSessionManager | undefined;
private readonly tmallLiveService: TmallLiveService | undefined;
private tmallSessionManager: TmallSessionManager | undefined;
private readonly executionMode: TaskExecutionMode;
constructor(options: InMemoryTaskStoreOptions = {}) {
this.storagePath = options.storagePath;
@ -436,6 +446,8 @@ export class InMemoryTaskStore {
this.jdSessionManager = options.jdSessionManager;
this.tmallLiveService = options.tmallLiveService;
this.tmallSessionManager = options.tmallSessionManager;
this.executionMode =
options.executionMode ?? (process.env.NODE_ENV === "test" ? "synchronous" : "background");
const persistedState = this.loadPersistedState();
if (persistedState) {
@ -483,6 +495,12 @@ export class InMemoryTaskStore {
return this.toSessionRecord(next);
}
notifyManagedSessionReady(platform: PlatformId): SessionStateRecord {
const session = this.preparePlatform(platform);
void this.resumeTasksWaitingForManagedSession(platform);
return session;
}
clearPlatformSession(platform: PlatformId): void {
const cleared = createMissingSession(platform);
this.readiness.set(platform, cleared);
@ -567,6 +585,24 @@ export class InMemoryTaskStore {
return this.tasks.get(taskId);
}
subscribeToTask(taskId: string, listener: (task: TaskRecord) => void): () => void {
const listeners = this.taskSubscribers.get(taskId) ?? new Set<(task: TaskRecord) => void>();
listeners.add(listener);
this.taskSubscribers.set(taskId, listeners);
return () => {
const currentListeners = this.taskSubscribers.get(taskId);
if (!currentListeners) {
return;
}
currentListeners.delete(listener);
if (currentListeners.size === 0) {
this.taskSubscribers.delete(taskId);
}
};
}
getCandidates(taskId: string): Record<PlatformId, CandidateRecord[]> | undefined {
return this.tasks.get(taskId)?.platformCandidates;
}
@ -714,6 +750,12 @@ export class InMemoryTaskStore {
task.taskStage = "session_check";
this.pushEvent(task, "task.running", "系统开始执行抓取前校验。");
if (this.executionMode === "background") {
this.persistState();
this.scheduleTaskExecution(task.taskId);
return task;
}
await this.executeSelectedPlatforms(task, selectedRuns);
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
task.updatedAt = nowIso();
@ -1664,6 +1706,44 @@ export class InMemoryTaskStore {
this.pushEvent(task, "task.awaiting_confirmation", "候选召回完成,等待人工确认。");
}
private async resumeTasksWaitingForManagedSession(platform: PlatformId): Promise<void> {
const taskIds = Array.from(this.tasks.values())
.filter((task) => this.shouldResumeTaskAfterManagedSessionReady(task, platform))
.map((task) => task.taskId);
for (const taskId of taskIds) {
const retryKey = `${taskId}:${platform}`;
if (this.pendingManagedSessionRetries.has(retryKey)) {
continue;
}
this.pendingManagedSessionRetries.add(retryKey);
try {
await this.retryPlatform(taskId, platform);
} catch {
// retryPlatform already records recoverable failures on the task.
} finally {
this.pendingManagedSessionRetries.delete(retryKey);
}
}
}
private shouldResumeTaskAfterManagedSessionReady(
task: TaskRecord,
platform: PlatformId
): boolean {
const run = task.platformRuns.find((item) => item.platform === platform);
if (!run) {
return false;
}
if (run.status === "SearchBlocked") {
return true;
}
return run.status === "Blocked" && run.selectedCandidateIds.length > 0;
}
private buildReport(task: TaskRecord): ReportSnapshot {
const reportVersion = this.getNextReportVersion(task);
const completedRuns = task.platformRuns.filter((run) => run.status === "Completed");
@ -2064,6 +2144,11 @@ export class InMemoryTaskStore {
run.status = "Searching";
run.reason = undefined;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.searching`,
`${platformCatalogMap[run.platform].label} 正在执行候选搜索。`
);
let candidates: CandidateRecord[] = [];
if (run.platform === "jd" && this.hasConfiguredJdLiveSession()) {
@ -2963,6 +3048,85 @@ export class InMemoryTaskStore {
};
task.events.push(event);
task.updatedAt = event.createdAt;
this.emitTaskSnapshot(task);
}
private emitTaskSnapshot(task: TaskRecord): void {
const listeners = this.taskSubscribers.get(task.taskId);
if (!listeners || listeners.size === 0) {
return;
}
for (const listener of listeners) {
listener(task);
}
}
private scheduleTaskExecution(taskId: string): void {
if (this.pendingExecutions.has(taskId)) {
return;
}
const execution = new Promise<void>((resolve) => {
setTimeout(() => {
void (async () => {
const task = this.requireTask(taskId);
const selectedRuns = task.platformRuns.filter((run) => run.status === "Selected");
if (selectedRuns.length > 0) {
await this.executeSelectedPlatforms(task, selectedRuns);
}
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
task.updatedAt = nowIso();
const published = this.publishReportIfNeeded(task);
this.persistState();
if (!published) {
this.emitTaskSnapshot(task);
}
})()
.catch((error) => {
this.failBackgroundTaskExecution(taskId, error);
})
.finally(() => {
this.pendingExecutions.delete(taskId);
resolve();
});
}, 0);
});
this.pendingExecutions.set(taskId, execution);
}
private failBackgroundTaskExecution(taskId: string, error: unknown): void {
const task = this.tasks.get(taskId);
if (!task) {
return;
}
for (const run of task.platformRuns) {
if (run.selectedCandidateIds.length === 0) {
continue;
}
if (run.status === "Selected" || run.status === "Running") {
run.status = "Failed";
run.reason = "后台执行异常中断,请稍后重试。";
run.lastUpdatedAt = nowIso();
}
}
task.taskStage = "publish";
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
this.pushEvent(
task,
"task.execution_failed",
error instanceof Error
? `后台执行异常中断:${error.message}`
: "后台执行异常中断,请稍后重试。"
);
this.persistState();
}
private requireTask(taskId: string): TaskRecord {

View File

@ -29,6 +29,7 @@ import {
clearJdSessionManagerConfig,
clearPlatformSession,
confirmTask,
createTaskEventsSource,
createTask,
deleteTask,
getJdKeywordPreview,
@ -80,6 +81,50 @@ function isRetryablePlatformStatus(
return status === "SearchBlocked" || status === "Blocked" || status === "Failed";
}
function useTaskLiveSync(
taskId: string,
options: {
includeCandidates?: boolean;
} = {}
) {
const queryClient = useQueryClient();
useEffect(() => {
if (!taskId) {
return;
}
const eventSource = createTaskEventsSource(taskId);
const handleSnapshot = (event: MessageEvent<string>) => {
const payload = JSON.parse(event.data) as { task: TaskRecord };
queryClient.setQueryData(["task", taskId], payload);
if (options.includeCandidates) {
void queryClient.invalidateQueries({ queryKey: ["task-candidates", taskId] });
}
if (
payload.task.taskStatus === "Completed" ||
payload.task.taskStatus === "PartialCompleted" ||
payload.task.taskStatus === "Blocked" ||
payload.task.taskStatus === "Failed"
) {
void queryClient.invalidateQueries({ queryKey: ["history"] });
if (payload.task.defaultReportVersion) {
void queryClient.invalidateQueries({ queryKey: ["report", taskId] });
}
}
};
eventSource.addEventListener("task.snapshot", handleSnapshot as EventListener);
return () => {
eventSource.removeEventListener("task.snapshot", handleSnapshot as EventListener);
eventSource.close();
};
}, [options.includeCandidates, queryClient, taskId]);
}
function getTaskDestination(
taskId: string,
taskStatus: TaskStatus,
@ -552,6 +597,8 @@ function CandidateCard(props: {
function ConfirmPage() {
const navigate = useNavigate();
const { taskId = "" } = useParams();
const queryClient = useQueryClient();
useTaskLiveSync(taskId, { includeCandidates: true });
const taskQuery = useQuery({
queryKey: ["task", taskId],
queryFn: () => getTask(taskId)
@ -590,6 +637,16 @@ function ConfirmPage() {
navigate(`/tasks/${task.taskId}/run`);
}
});
const retryMutation = useMutation({
mutationFn: (platform: PlatformId) => retryTaskPlatform(taskId, platform),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["task", taskId] }),
queryClient.invalidateQueries({ queryKey: ["task-candidates", taskId] }),
queryClient.invalidateQueries({ queryKey: ["history"] })
]);
}
});
if (!taskQuery.data || !candidatesQuery.data) {
return (
@ -625,9 +682,27 @@ function ConfirmPage() {
<p>{platformRun?.reason ?? "当前没有候选结果。"}</p>
{platformRun?.status === "SearchBlocked" ? (
isOpsManagedPlatform(platform) ? (
<span className="inline-note inline-note--subtle">
</span>
<>
<span className="inline-note inline-note--subtle">
</span>
<div className="panel-actions">
<a
className="text-link"
href={buildOpsSessionManagerHref(platform, `/tasks/${taskId}/confirm`)}
>
</a>
<button
className="ghost-button"
disabled={retryMutation.isPending && retryMutation.variables === platform}
onClick={() => retryMutation.mutate(platform)}
type="button"
>
</button>
</div>
</>
) : (
<a
className="text-link"
@ -637,6 +712,18 @@ function ConfirmPage() {
</a>
)
) : null}
{platformRun?.status === "Failed" ? (
<div className="panel-actions">
<button
className="ghost-button"
disabled={retryMutation.isPending && retryMutation.variables === platform}
onClick={() => retryMutation.mutate(platform)}
type="button"
>
</button>
</div>
) : null}
</div>
) : (
<div className="stack">
@ -683,6 +770,7 @@ function ConfirmPage() {
function RunPage() {
const queryClient = useQueryClient();
const { taskId = "" } = useParams();
useTaskLiveSync(taskId);
const taskQuery = useQuery({
queryKey: ["task", taskId],
queryFn: () => getTask(taskId)

View File

@ -1,11 +1,13 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./lib/api", () => ({
cancelJdQrLogin: vi.fn(),
cancelTmallQrLogin: vi.fn(),
clearJdManagedSession: vi.fn(),
clearJdSessionManagerConfig: vi.fn(),
clearPlatformSession: vi.fn(),
@ -13,9 +15,15 @@ vi.mock("./lib/api", () => ({
clearTmallSessionManagerConfig: vi.fn(),
confirmTask: vi.fn(),
createTask: vi.fn(),
createTaskEventsSource: vi.fn(() => ({
addEventListener: vi.fn(),
close: vi.fn(),
removeEventListener: vi.fn()
})),
deleteTask: vi.fn(),
getJdKeywordPreview: vi.fn(),
getJdLiveSession: vi.fn(),
getJdQrLoginState: vi.fn(),
getJdSessionManager: vi.fn(),
getHistoryTasks: vi.fn(),
getPlatformReadiness: vi.fn(),
@ -24,37 +32,50 @@ vi.mock("./lib/api", () => ({
getTaskCandidates: vi.fn(),
getTaskReport: vi.fn(),
getTmallLiveSession: vi.fn(),
getTmallQrLoginState: vi.fn(),
getTmallSessionManager: vi.fn(),
importJdManagedSession: vi.fn(),
importTmallManagedSession: vi.fn(),
preparePlatform: vi.fn(),
resumeJdQrLoginManualRecovery: vi.fn(),
runJdSessionManagerHealthCheck: vi.fn(),
runJdSessionManagerRecovery: vi.fn(),
runTmallSessionManagerHealthCheck: vi.fn(),
retryTaskPlatform: vi.fn(),
startJdQrLogin: vi.fn(),
startTmallQrLogin: vi.fn(),
updateJdSessionManagerConfig: vi.fn(),
updateTmallSessionManagerConfig: vi.fn()
}));
import { App, NewTaskPage } from "./App";
import {
cancelJdQrLogin,
cancelTmallQrLogin,
clearJdManagedSession,
clearPlatformSession,
clearTmallManagedSession,
getJdKeywordPreview,
getJdLiveSession,
getJdQrLoginState,
getJdSessionManager,
getHistoryTasks,
getPlatformReadiness,
getPlatformSession,
getTask,
getTaskCandidates,
getTmallLiveSession,
getTmallQrLoginState,
getTmallSessionManager,
importJdManagedSession,
importTmallManagedSession,
preparePlatform,
createTaskEventsSource,
runJdSessionManagerHealthCheck,
runJdSessionManagerRecovery,
runTmallSessionManagerHealthCheck,
startJdQrLogin,
startTmallQrLogin,
updateJdSessionManagerConfig
} from "./lib/api";
@ -219,6 +240,14 @@ describe("task composer and session console", () => {
}
}
} as any);
vi.mocked(getJdQrLoginState).mockResolvedValue({
qrLogin: {
platform: "jd",
status: "idle",
note: "尚未启动扫码登录。",
sessionImported: false
}
} as any);
vi.mocked(getJdKeywordPreview).mockResolvedValue({
preview: {
query: "小米手环10",
@ -323,6 +352,14 @@ describe("task composer and session console", () => {
vi.mocked(getTmallLiveSession).mockResolvedValue({
session: tmallManagerState.session
} as any);
vi.mocked(getTmallQrLoginState).mockResolvedValue({
qrLogin: {
platform: "tmall",
status: "idle",
note: "尚未启动扫码登录。",
sessionImported: false
}
} as any);
vi.mocked(clearPlatformSession).mockResolvedValue(undefined);
vi.mocked(importJdManagedSession).mockResolvedValue({
manager: {
@ -408,10 +445,46 @@ describe("task composer and session console", () => {
state: jdManagerState,
recovered: true
} as any);
vi.mocked(startJdQrLogin).mockResolvedValue({
qrLogin: {
platform: "jd",
status: "waiting_for_scan",
note: "二维码已生成,请扫码。",
targetId: "100068388533",
qrImageDataUrl: "data:image/png;base64,stub",
sessionImported: false
}
} as any);
vi.mocked(cancelJdQrLogin).mockResolvedValue({
qrLogin: {
platform: "jd",
status: "cancelled",
note: "扫码已取消。",
sessionImported: false
}
} as any);
vi.mocked(runTmallSessionManagerHealthCheck).mockResolvedValue({
state: tmallManagerState,
recovered: false
} as any);
vi.mocked(startTmallQrLogin).mockResolvedValue({
qrLogin: {
platform: "tmall",
status: "waiting_for_scan",
note: "二维码已生成,请扫码。",
targetId: "934454505228",
qrImageDataUrl: "data:image/png;base64,stub",
sessionImported: false
}
} as any);
vi.mocked(cancelTmallQrLogin).mockResolvedValue({
qrLogin: {
platform: "tmall",
status: "cancelled",
note: "扫码已取消。",
sessionImported: false
}
} as any);
vi.mocked(preparePlatform).mockResolvedValue({
platform: "jd",
session_ready: true,
@ -480,6 +553,134 @@ describe("task composer and session console", () => {
expect(screen.getByText(/返回业务页面:\/tasks\/new/)).toBeInTheDocument();
});
it("updates the confirm page when a task snapshot restores JD candidates", async () => {
let snapshotHandler: ((event: MessageEvent<string>) => void) | undefined;
vi.mocked(createTaskEventsSource).mockReturnValue({
addEventListener: vi.fn((_type: string, handler: EventListenerOrEventListenerObject) => {
snapshotHandler = handler as (event: MessageEvent<string>) => void;
}),
close: vi.fn(),
removeEventListener: vi.fn()
} as unknown as EventSource);
vi.mocked(getTask).mockResolvedValue({
task: {
taskId: "task-confirm-live",
query: "iPhone 15",
createdAt: "2026-04-07T09:00:00.000Z",
updatedAt: "2026-04-07T09:00:00.000Z",
perLinkLimit: 100,
taskTotalLimit: 500,
taskStatus: "AwaitingConfirmation",
taskStage: "confirmation",
platformRuns: [
{
platform: "tmall",
searchRequirement: "recommended",
status: "AwaitingSelection",
candidateCount: 1,
selectedCandidateIds: [],
lastUpdatedAt: "2026-04-07T09:00:00.000Z"
},
{
platform: "jd",
searchRequirement: "required",
status: "SearchBlocked",
candidateCount: 0,
selectedCandidateIds: [],
reason: "waiting for ops recovery",
lastUpdatedAt: "2026-04-07T09:00:00.000Z"
}
],
platformCandidates: {
tmall: [],
jd: []
},
events: [],
reportVersions: []
}
} as any);
vi.mocked(getTaskCandidates)
.mockResolvedValueOnce({
candidates: {
tmall: [],
jd: []
}
} as any)
.mockResolvedValue({
candidates: {
tmall: [],
jd: [
{
candidateId: "jd-100068388533",
platform: "jd",
title: "Apple iPhone 15",
price: 3898,
priceLabel: "CNY 3898",
storeName: "JD Self Operated",
productUrl: "https://item.jd.com/100068388533.html",
imageUrl: "https://img14.360buyimg.com/example.jpg",
salesHint: "sold 500+",
specLabel: "128GB",
highlights: ["A16"]
}
]
}
} as any);
renderWithProviders(<App />, ["/tasks/task-confirm-live/confirm"]);
expect(await screen.findByText("waiting for ops recovery")).toBeInTheDocument();
await act(async () => {
snapshotHandler?.(
new MessageEvent("task.snapshot", {
data: JSON.stringify({
task: {
taskId: "task-confirm-live",
query: "iPhone 15",
createdAt: "2026-04-07T09:00:00.000Z",
updatedAt: "2026-04-07T09:01:00.000Z",
perLinkLimit: 100,
taskTotalLimit: 500,
taskStatus: "AwaitingConfirmation",
taskStage: "confirmation",
platformRuns: [
{
platform: "tmall",
searchRequirement: "recommended",
status: "AwaitingSelection",
candidateCount: 1,
selectedCandidateIds: [],
lastUpdatedAt: "2026-04-07T09:00:00.000Z"
},
{
platform: "jd",
searchRequirement: "required",
status: "AwaitingSelection",
candidateCount: 1,
selectedCandidateIds: [],
lastUpdatedAt: "2026-04-07T09:01:00.000Z"
}
],
platformCandidates: {
tmall: [],
jd: []
},
events: [],
reportVersions: []
}
})
})
);
});
expect(await screen.findByText("Apple iPhone 15")).toBeInTheDocument();
await waitFor(() => {
expect(getTaskCandidates).toHaveBeenCalledTimes(2);
});
});
it("imports jd managed session payload from the ops page", async () => {
const user = userEvent.setup();

View File

@ -4,23 +4,31 @@ import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
cancelJdQrLogin,
cancelTmallQrLogin,
clearJdManagedSession,
clearJdSessionManagerConfig,
clearTmallManagedSession,
clearTmallSessionManagerConfig,
getJdLiveSession,
getJdQrLoginState,
getJdSessionManager,
getTmallLiveSession,
getTmallQrLoginState,
getTmallSessionManager,
importJdManagedSession,
importTmallManagedSession,
runJdSessionManagerHealthCheck,
runJdSessionManagerRecovery,
runTmallSessionManagerHealthCheck,
resumeJdQrLoginManualRecovery,
type JdLiveSessionInput,
type JdSessionManagerConfigInput,
type OpsQrLoginState,
type TmallLiveSessionInput,
type TmallSessionManagerConfigInput,
startJdQrLogin,
startTmallQrLogin,
updateJdSessionManagerConfig,
updateTmallSessionManagerConfig
} from "./lib/api";
@ -52,6 +60,112 @@ function formatTimestamp(timestamp?: string) {
return new Date(timestamp).toLocaleString("zh-CN");
}
const SCREENSHOT_POLL_INTERVAL_MS = 1500;
function isActiveQrLogin(state?: OpsQrLoginState) {
return (
state?.status === "launching" ||
state?.status === "waiting_for_scan" ||
state?.status === "capturing_session"
);
}
function QrLoginCard(props: {
platformLabel: string;
platformHint: string;
qrLogin?: OpsQrLoginState | undefined;
onStart: () => void;
onCancel: () => void;
onResumeManualRecovery?: (() => void) | undefined;
startPending: boolean;
cancelPending: boolean;
resumePending?: boolean | undefined;
}) {
const active = isActiveQrLogin(props.qrLogin);
const canResumeManualRecovery =
props.qrLogin?.status === "failed" &&
props.qrLogin.sessionImported &&
Boolean(props.onResumeManualRecovery);
const startLabel = active ? "重新生成二维码" : "开始扫码登录";
const qrPreview = active ? props.qrLogin?.qrImageDataUrl : undefined;
let emptyState = "点击“开始扫码登录”后,这里会显示实时二维码截图。";
if (props.qrLogin?.status === "launching") {
emptyState = "二维码生成中...";
} else if (props.qrLogin?.status === "failed") {
emptyState = "上一次二维码已失效,请点击“重新生成二维码”。";
} else if (props.qrLogin?.status === "cancelled") {
emptyState = "当前扫码流程已取消,请重新生成二维码。";
} else if (props.qrLogin?.status === "completed") {
emptyState = "扫码流程已完成,可在右侧查看会话导入状态。";
}
return (
<div className="page-panel ops-panel">
<p className="eyebrow">QR Login</p>
<strong>{props.platformLabel}</strong>
<p className="inline-note">{props.platformHint}</p>
<div className="qr-login-card">
<div className="qr-login-card__preview">
{qrPreview ? (
<img alt={`${props.platformLabel} 登录二维码`} src={qrPreview} />
) : (
<div className="qr-login-card__empty">{emptyState}</div>
)}
</div>
<div className="stack stack--dense">
<div className="session-details">
<div className="session-details__row">
<span>Status</span>
<strong>{props.qrLogin?.status ?? "idle"}</strong>
</div>
<div className="session-details__row">
<span>Target</span>
<strong>{props.qrLogin?.targetId ?? "默认目标"}</strong>
</div>
<div className="session-details__row">
<span>Updated</span>
<strong>{formatTimestamp(props.qrLogin?.updatedAt)}</strong>
</div>
<p>{props.qrLogin?.note ?? "尚未启动扫码流程。"}</p>
{props.qrLogin?.currentUrl ? (
<p className="session-return-target">{props.qrLogin.currentUrl}</p>
) : null}
</div>
<div className="panel-actions">
<button
className="primary-button"
disabled={props.startPending}
onClick={props.onStart}
type="button"
>
{startLabel}
</button>
<button
className="ghost-button"
disabled={props.cancelPending || !active}
onClick={props.onCancel}
type="button"
>
</button>
{canResumeManualRecovery ? (
<button
className="ghost-button"
disabled={props.resumePending}
onClick={props.onResumeManualRecovery}
type="button"
>
</button>
) : null}
</div>
</div>
</div>
</div>
);
}
export function OpsSessionManagerPage() {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
@ -102,6 +216,12 @@ export function OpsSessionManagerPage() {
queryKey: ["jd-live-session"],
queryFn: getJdLiveSession
});
const jdQrLoginQuery = useQuery({
queryKey: ["jd-qr-login"],
queryFn: getJdQrLoginState,
refetchInterval: (query) =>
isActiveQrLogin(query.state.data?.qrLogin) ? SCREENSHOT_POLL_INTERVAL_MS : false
});
const tmallManagerQuery = useQuery({
queryKey: ["tmall-session-manager"],
queryFn: getTmallSessionManager
@ -110,11 +230,18 @@ export function OpsSessionManagerPage() {
queryKey: ["tmall-live-session"],
queryFn: getTmallLiveSession
});
const tmallQrLoginQuery = useQuery({
queryKey: ["tmall-qr-login"],
queryFn: getTmallQrLoginState,
refetchInterval: (query) =>
isActiveQrLogin(query.state.data?.qrLogin) ? SCREENSHOT_POLL_INTERVAL_MS : false
});
const invalidateJdOpsQueries = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["jd-session-manager"] }),
queryClient.invalidateQueries({ queryKey: ["jd-live-session"] }),
queryClient.invalidateQueries({ queryKey: ["jd-qr-login"] }),
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
]);
};
@ -123,6 +250,7 @@ export function OpsSessionManagerPage() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["tmall-session-manager"] }),
queryClient.invalidateQueries({ queryKey: ["tmall-live-session"] }),
queryClient.invalidateQueries({ queryKey: ["tmall-qr-login"] }),
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
]);
};
@ -157,6 +285,18 @@ export function OpsSessionManagerPage() {
mutationFn: clearJdManagedSession,
onSuccess: invalidateJdOpsQueries
});
const jdStartQrLoginMutation = useMutation({
mutationFn: startJdQrLogin,
onSuccess: invalidateJdOpsQueries
});
const jdCancelQrLoginMutation = useMutation({
mutationFn: cancelJdQrLogin,
onSuccess: invalidateJdOpsQueries
});
const jdResumeQrLoginMutation = useMutation({
mutationFn: resumeJdQrLoginManualRecovery,
onSuccess: invalidateJdOpsQueries
});
const tmallSaveConfigMutation = useMutation({
mutationFn: (payload: TmallSessionManagerConfigInput) =>
@ -185,11 +325,21 @@ export function OpsSessionManagerPage() {
mutationFn: clearTmallManagedSession,
onSuccess: invalidateTmallOpsQueries
});
const tmallStartQrLoginMutation = useMutation({
mutationFn: startTmallQrLogin,
onSuccess: invalidateTmallOpsQueries
});
const tmallCancelQrLoginMutation = useMutation({
mutationFn: cancelTmallQrLogin,
onSuccess: invalidateTmallOpsQueries
});
const jdManager = jdManagerQuery.data?.manager;
const jdLiveSession = jdLiveSessionQuery.data?.session;
const jdQrLogin = jdQrLoginQuery.data?.qrLogin;
const tmallManager = tmallManagerQuery.data?.manager;
const tmallLiveSession = tmallLiveSessionQuery.data?.session;
const tmallQrLogin = tmallQrLoginQuery.data?.qrLogin;
useEffect(() => {
if (!jdManager || jdConfigDirty) {
@ -221,6 +371,22 @@ export function OpsSessionManagerPage() {
});
}, [tmallConfigDirty, tmallManager]);
useEffect(() => {
if (!jdQrLogin?.sessionImported || jdQrLogin.status !== "completed") {
return;
}
void invalidateJdOpsQueries();
}, [jdQrLogin?.completedAt]);
useEffect(() => {
if (!tmallQrLogin?.sessionImported || tmallQrLogin.status !== "completed") {
return;
}
void invalidateTmallOpsQueries();
}, [tmallQrLogin?.completedAt]);
const switchPlatform = (platform: PlatformId) => {
const next = new URLSearchParams(searchParams);
next.set("platform", platform);
@ -323,6 +489,18 @@ export function OpsSessionManagerPage() {
</p>
</div>
<QrLoginCard
cancelPending={jdCancelQrLoginMutation.isPending}
onCancel={() => jdCancelQrLoginMutation.mutate()}
onResumeManualRecovery={() => jdResumeQrLoginMutation.mutate()}
onStart={() => jdStartQrLoginMutation.mutate()}
platformHint="后端会启动受控浏览器,实时截图京东登录二维码;扫码成功后会自动抓取 Cookie、详情模板和评论模板并导入当前运维会话。"
platformLabel="京东"
qrLogin={jdQrLogin}
resumePending={jdResumeQrLoginMutation.isPending}
startPending={jdStartQrLoginMutation.isPending}
/>
<div className="page-panel ops-panel">
<p className="eyebrow">Automation</p>
<strong></strong>
@ -604,6 +782,10 @@ export function OpsSessionManagerPage() {
<span>Live Session</span>
<strong>{jdLiveSession?.configured ? "已导入" : "未导入"}</strong>
</div>
<div className="session-details__row">
<span>QR Login</span>
<strong>{jdQrLogin?.status ?? "idle"}</strong>
</div>
<div className="session-details__row">
<span>Detail Template</span>
<strong>
@ -656,6 +838,24 @@ export function OpsSessionManagerPage() {
{jdClearSessionMutation.error.message}
</p>
) : null}
{jdQrLoginQuery.error instanceof Error ? (
<p className="inline-note inline-note--subtle">{jdQrLoginQuery.error.message}</p>
) : null}
{jdStartQrLoginMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{jdStartQrLoginMutation.error.message}
</p>
) : null}
{jdCancelQrLoginMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{jdCancelQrLoginMutation.error.message}
</p>
) : null}
{jdResumeQrLoginMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{jdResumeQrLoginMutation.error.message}
</p>
) : null}
</div>
</div>
) : (
@ -670,6 +870,16 @@ export function OpsSessionManagerPage() {
</p>
</div>
<QrLoginCard
cancelPending={tmallCancelQrLoginMutation.isPending}
onCancel={() => tmallCancelQrLoginMutation.mutate()}
onStart={() => tmallStartQrLoginMutation.mutate()}
platformHint="后端会启动受控浏览器,实时截图淘宝/天猫登录二维码;扫码成功后会自动导入 Cookie并优先抓取或回退生成评论模板。"
platformLabel="天猫"
qrLogin={tmallQrLogin}
startPending={tmallStartQrLoginMutation.isPending}
/>
<div className="page-panel ops-panel">
<p className="eyebrow">Automation</p>
<strong></strong>
@ -874,6 +1084,10 @@ export function OpsSessionManagerPage() {
<span>Live Session</span>
<strong>{tmallLiveSession?.configured ? "已导入" : "未导入"}</strong>
</div>
<div className="session-details__row">
<span>QR Login</span>
<strong>{tmallQrLogin?.status ?? "idle"}</strong>
</div>
<div className="session-details__row">
<span>Detail Template</span>
<strong>
@ -927,6 +1141,19 @@ export function OpsSessionManagerPage() {
{tmallClearSessionMutation.error.message}
</p>
) : null}
{tmallQrLoginQuery.error instanceof Error ? (
<p className="inline-note inline-note--subtle">{tmallQrLoginQuery.error.message}</p>
) : null}
{tmallStartQrLoginMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{tmallStartQrLoginMutation.error.message}
</p>
) : null}
{tmallCancelQrLoginMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{tmallCancelQrLoginMutation.error.message}
</p>
) : null}
</div>
</div>
)}

View File

@ -37,6 +37,7 @@ a {
.app-shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
align-items: start;
min-height: 100vh;
}
@ -44,6 +45,12 @@ a {
display: flex;
flex-direction: column;
justify-content: space-between;
position: sticky;
top: 0;
align-self: start;
height: 100vh;
height: 100dvh;
overflow: hidden;
padding: 32px 24px;
border-right: 1px solid rgba(31, 42, 48, 0.08);
background: rgba(251, 248, 242, 0.72);
@ -75,6 +82,7 @@ a {
.main-content {
display: grid;
gap: 24px;
min-height: 100vh;
padding: 32px;
}
@ -669,6 +677,39 @@ a {
padding: 0 12px;
border-radius: 999px;
background: rgba(20, 108, 110, 0.08);
.qr-login-card {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.qr-login-card__preview {
display: grid;
place-items: center;
min-height: 248px;
padding: 16px;
border-radius: 18px;
background:
linear-gradient(180deg, rgba(20, 108, 110, 0.08) 0%, rgba(255, 255, 255, 0.88) 100%);
border: 1px solid rgba(20, 108, 110, 0.14);
}
.qr-login-card__preview img {
display: block;
width: min(100%, 240px);
max-height: 240px;
object-fit: contain;
border-radius: 12px;
background: white;
}
.qr-login-card__empty {
color: var(--text-secondary);
text-align: center;
line-height: 1.6;
}
color: var(--brand-primary);
font-size: 13px;
font-weight: 700;
@ -711,3 +752,7 @@ a {
display: none;
}
}
.qr-login-card {
grid-template-columns: 1fr;
}

15
package-lock.json generated
View File

@ -27,7 +27,8 @@
"@cross-ai/domain": "file:../../packages/domain",
"@cross-ai/report-schema": "file:../../packages/report-schema",
"@fastify/cors": "^11.2.0",
"fastify": "^5.8.4"
"fastify": "^5.8.4",
"playwright-core": "^1.59.1"
}
},
"apps/web": {
@ -3404,6 +3405,18 @@
"pathe": "^2.0.1"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",