feat: 接入运维扫码登录与任务实时执行链路
This commit is contained in:
parent
8c4d057202
commit
4ea9eaf4a6
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
214
apps/api/src/ops/qr-login.test.ts
Normal file
214
apps/api/src/ops/qr-login.test.ts
Normal 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
1243
apps/api/src/ops/qr-login.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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({
|
||||
|
||||
@ -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,10 +646,9 @@ 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");
|
||||
|
||||
try {
|
||||
if (resolvedMode === "api") {
|
||||
if (!session.searchApiTemplateUrl) {
|
||||
throw new JdLiveError(
|
||||
@ -539,28 +658,20 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetchTextOrThrow(
|
||||
const requestUrl = buildSearchApiRequestUrl(session.searchApiTemplateUrl, normalizedQuery);
|
||||
const referer = buildSearchPageUrlFromTemplate(
|
||||
session.searchApiTemplateUrl,
|
||||
normalizedQuery
|
||||
);
|
||||
const response = await fetchTextOrThrow(
|
||||
requestUrl,
|
||||
{
|
||||
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: "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."
|
||||
);
|
||||
@ -578,14 +689,13 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
const response = await fetchTextOrThrow(
|
||||
searchUrl,
|
||||
{
|
||||
headers: {
|
||||
Accept:
|
||||
headers: buildReplayHeaders(session.searchRequestHeaders, {
|
||||
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
|
||||
}
|
||||
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."
|
||||
);
|
||||
@ -597,6 +707,14 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
candidateCount: candidates.length,
|
||||
candidates
|
||||
};
|
||||
} catch (error) {
|
||||
const browserPreview = await this.tryBrowserSearchFallback(error, normalizedQuery, resolvedMode);
|
||||
if (browserPreview) {
|
||||
return browserPreview;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async previewDetail(skuId: string): Promise<JdDetailPreviewResult> {
|
||||
@ -616,15 +734,16 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
|
||||
const requestUrl = buildDetailRequestUrl(session.detailTemplateUrl, normalizedSkuId);
|
||||
|
||||
try {
|
||||
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
|
||||
}
|
||||
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."
|
||||
);
|
||||
@ -643,6 +762,14 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
source: "api",
|
||||
detail
|
||||
};
|
||||
} catch (error) {
|
||||
const browserPreview = await this.tryBrowserDetailFallback(error, normalizedSkuId);
|
||||
if (browserPreview) {
|
||||
return browserPreview;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async previewReviews(
|
||||
@ -668,6 +795,7 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
let pageKey: string | undefined;
|
||||
const seenCommentIds = new Set<string>();
|
||||
|
||||
try {
|
||||
for (let pageOffset = 0; pageOffset < resolvedOptions.maxPages; pageOffset += 1) {
|
||||
const currentPage = resolvedOptions.page + pageOffset;
|
||||
const request = buildReviewsRequestUrl(
|
||||
@ -681,12 +809,12 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
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
|
||||
}
|
||||
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."
|
||||
);
|
||||
@ -726,6 +854,18 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
pagination,
|
||||
reviews: mergeReviewPages(normalizedSkuId, reviewPages)
|
||||
};
|
||||
} catch (error) {
|
||||
const browserPreview = await this.tryBrowserReviewsFallback(
|
||||
error,
|
||||
normalizedSkuId,
|
||||
options
|
||||
);
|
||||
if (browserPreview) {
|
||||
return browserPreview;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
})
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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())
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)}\n\n`);
|
||||
reply.raw.end();
|
||||
return reply;
|
||||
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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
<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)
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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
15
package-lock.json
generated
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user