fix: 补齐前端实测与恢复重试链路

This commit is contained in:
2026-04-02 16:57:21 +08:00
parent a5b079a673
commit 99718c94fd
6 changed files with 733 additions and 150 deletions
+166 -27
View File
@@ -2,6 +2,27 @@ import { describe, expect, it } from "vitest";
import { createServer } from "./server";
async function createTask(app: ReturnType<typeof createServer>, query: string) {
const response = await app.inject({
method: "POST",
url: "/api/tasks",
payload: {
query,
perLinkLimit: 100,
taskTotalLimit: 500
}
});
return response.json().task;
}
async function preparePlatform(app: ReturnType<typeof createServer>, platform: "tmall" | "jd") {
await app.inject({
method: "POST",
url: `/api/platforms/${platform}/prepare`
});
}
describe("API server", () => {
it("returns platform readiness with jd blocked by default", async () => {
const app = createServer();
@@ -65,20 +86,11 @@ describe("API server", () => {
const app = createServer();
await app.ready();
const createResponse = await app.inject({
method: "POST",
url: "/api/tasks",
payload: {
query: "DJI Pocket 3",
perLinkLimit: 100,
taskTotalLimit: 500
}
});
const taskId = createResponse.json().task.taskId;
const createdTask = await createTask(app, "DJI Pocket 3");
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${taskId}/confirm`,
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: []
}
@@ -89,7 +101,7 @@ describe("API server", () => {
const reportResponse = await app.inject({
method: "GET",
url: `/api/tasks/${taskId}/report`
url: `/api/tasks/${createdTask.taskId}/report`
});
expect(reportResponse.statusCode).toBe(404);
@@ -100,27 +112,17 @@ describe("API server", () => {
const app = createServer();
await app.ready();
const createResponse = await app.inject({
method: "POST",
url: "/api/tasks",
payload: {
query: "Nintendo Switch 2",
perLinkLimit: 80,
taskTotalLimit: 320
}
});
const taskId = createResponse.json().task.taskId;
const createdTask = await createTask(app, "Nintendo Switch 2");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${taskId}/candidates`
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const firstCandidateId =
candidatesResponse.json().candidates.tmall[0].candidateId;
const firstCandidateId = candidatesResponse.json().candidates.tmall[0].candidateId;
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${taskId}/confirm`,
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
@@ -137,7 +139,7 @@ describe("API server", () => {
const reportResponse = await app.inject({
method: "GET",
url: `/api/tasks/${taskId}/report`
url: `/api/tasks/${createdTask.taskId}/report`
});
expect(reportResponse.statusCode).toBe(200);
@@ -146,4 +148,141 @@ describe("API server", () => {
await app.close();
});
it("retries a SearchBlocked platform after session preparation and restores candidates", async () => {
const app = createServer();
await app.ready();
const createdTask = await createTask(app, "iPhone 15 Pro");
await preparePlatform(app, "jd");
const retryResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry`
});
expect(retryResponse.statusCode).toBe(200);
expect(
retryResponse
.json()
.task.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(3);
await app.close();
});
it("publishes a new report version when a blocked platform recovers successfully", async () => {
const app = createServer();
await app.ready();
await preparePlatform(app, "jd");
const createdTask = await createTask(app, "Nintendo Switch 2 [jd:blocked-once]");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const candidates = candidatesResponse.json().candidates;
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "tmall",
candidateIds: [candidates.tmall[0].candidateId]
},
{
platform: "jd",
candidateIds: [candidates.jd[0].candidateId]
}
]
}
});
expect(confirmResponse.statusCode).toBe(200);
expect(confirmResponse.json().task.taskStatus).toBe("PartialCompleted");
expect(confirmResponse.json().task.reportVersions).toEqual([1]);
const retryResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry`
});
expect(retryResponse.statusCode).toBe(200);
expect(retryResponse.json().task.taskStatus).toBe("Completed");
expect(retryResponse.json().task.reportVersions).toEqual([1, 2]);
expect(retryResponse.json().task.defaultReportVersion).toBe(2);
const firstReport = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/report?version=1`
});
const secondReport = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/report?version=2`
});
expect(firstReport.json().report.task_status).toBe("PartialCompleted");
expect(secondReport.json().report.task_status).toBe("Completed");
await app.close();
});
it("keeps the current report version when retry does not change the result", async () => {
const app = createServer();
await app.ready();
await preparePlatform(app, "jd");
const createdTask = await createTask(app, "Nintendo Switch 2 [jd:failed-always]");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const candidates = candidatesResponse.json().candidates;
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "tmall",
candidateIds: [candidates.tmall[0].candidateId]
},
{
platform: "jd",
candidateIds: [candidates.jd[0].candidateId]
}
]
}
});
expect(confirmResponse.json().task.taskStatus).toBe("PartialCompleted");
expect(confirmResponse.json().task.reportVersions).toEqual([1]);
const retryResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry`
});
expect(retryResponse.statusCode).toBe(200);
expect(retryResponse.json().task.taskStatus).toBe("PartialCompleted");
expect(retryResponse.json().task.reportVersions).toEqual([1]);
expect(retryResponse.json().task.defaultReportVersion).toBe(1);
await app.close();
});
});
+12
View File
@@ -78,6 +78,18 @@ export function createServer() {
}
});
app.post<{
Params: { taskId: string; platform: PlatformId };
}>("/api/tasks/:taskId/platforms/:platform/retry", async (request, reply) => {
try {
const task = store.retryPlatform(request.params.taskId, request.params.platform);
return { task };
} catch {
reply.code(404);
return { message: "Task not found." };
}
});
app.get<{
Params: { taskId: string };
Querystring: { version?: string };
+322 -69
View File
@@ -10,6 +10,7 @@ import {
type HistoryTaskRecord,
type PlatformId,
type PlatformRunRecord,
type PlatformStatus,
type SessionReadinessRecord,
type TaskEventRecord,
type TaskRecord
@@ -30,10 +31,58 @@ function createPlatformCandidatesRecord(): Record<PlatformId, CandidateRecord[]>
};
}
type MockExecutionScenario = {
outcome: Extract<PlatformStatus, "Blocked" | "Failed">;
mode: "once" | "always";
};
const mockScenarioPattern =
/\[(?<platform>tmall|jd):(?<outcome>blocked|failed)(?:-(?<mode>once|always))?\]/giu;
function parseMockExecutionScenarios(
query: string
): Partial<Record<PlatformId, MockExecutionScenario>> {
const scenarios: Partial<Record<PlatformId, MockExecutionScenario>> = {};
for (const match of query.matchAll(mockScenarioPattern)) {
const platform = match.groups?.platform as PlatformId | undefined;
const outcome = match.groups?.outcome;
const mode = match.groups?.mode;
if (!platform || (outcome !== "blocked" && outcome !== "failed")) {
continue;
}
scenarios[platform] = {
outcome: outcome === "blocked" ? "Blocked" : "Failed",
mode: mode === "always" ? "always" : "once"
};
}
return scenarios;
}
function isRetryableStatus(
status: PlatformStatus
): status is Extract<PlatformStatus, "SearchBlocked" | "Blocked" | "Failed"> {
return status === "SearchBlocked" || status === "Blocked" || status === "Failed";
}
function isReportableTaskStatus(
status: TaskRecord["taskStatus"]
): status is Extract<TaskRecord["taskStatus"], "Completed" | "PartialCompleted"> {
return status === "Completed" || status === "PartialCompleted";
}
export class InMemoryTaskStore {
private readonly tasks = new Map<string, TaskRecord>();
private readonly reports = new Map<string, ReportSnapshot[]>();
private readonly reportFingerprints = new Map<string, string[]>();
private readonly readiness = new Map<PlatformId, SessionReadinessRecord>();
private readonly executionScenarios = new Map<
string,
Partial<Record<PlatformId, MockExecutionScenario>>
>();
constructor() {
this.readiness.set("tmall", {
@@ -67,6 +116,7 @@ export class InMemoryTaskStore {
createTask(input: CreateTaskInput): TaskRecord {
const timestamp = nowIso();
const taskId = randomUUID();
const executionScenarios = parseMockExecutionScenarios(input.query);
const task: TaskRecord = {
taskId,
query: input.query.trim(),
@@ -91,6 +141,10 @@ export class InMemoryTaskStore {
reportVersions: []
};
if (Object.keys(executionScenarios).length > 0) {
this.executionScenarios.set(taskId, executionScenarios);
}
this.pushEvent(task, "task.created", "任务已创建,准备进入平台预检查。");
task.taskStatus = "Searching";
task.taskStage = "precheck";
@@ -168,35 +222,78 @@ export class InMemoryTaskStore {
task.taskStage = "session_check";
this.pushEvent(task, "task.running", "系统开始执行抓取前校验。");
task.taskStage = "crawl";
for (const run of selectedRuns) {
run.status = "Running";
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.running`,
`${platformCatalogMap[run.platform].label} 开始抓取商品与评论。`
);
run.status = "Completed";
run.lastUpdatedAt = nowIso();
}
task.taskStage = "normalize";
this.pushEvent(task, "task.normalize", "系统正在标准化商品与评论数据。");
task.taskStage = "analyze";
this.pushEvent(task, "task.analyze", "系统正在生成结构化报告。");
task.taskStage = "publish";
this.executeSelectedPlatforms(task, selectedRuns);
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
task.updatedAt = nowIso();
const report = this.buildReport(task);
const previousReports = this.reports.get(task.taskId) ?? [];
this.reports.set(task.taskId, [...previousReports, report]);
task.reportVersions = [...task.reportVersions, report.report_version];
task.defaultReportVersion = report.report_version;
task.latestSuccessfulReportVersion = report.report_version;
this.pushEvent(task, "task.report_published", `报告 v${report.report_version} 已生成。`);
this.publishReportIfNeeded(task);
return task;
}
retryPlatform(taskId: string, platform: PlatformId): TaskRecord {
const task = this.requireTask(taskId);
const run = task.platformRuns.find((item) => item.platform === platform);
if (!run) {
throw new Error(`Platform ${platform} not found.`);
}
if (!isRetryableStatus(run.status)) {
return task;
}
if (run.status === "SearchBlocked") {
const readiness = this.requireReadiness(platform);
if (readiness.searchRequirement === "required" && !readiness.ready) {
this.pushEvent(
task,
`platform.${platform}.retry_blocked`,
`${platformCatalogMap[platform].label} 仍缺少有效会话,无法重新搜索。`
);
return task;
}
this.pushEvent(
task,
`platform.${platform}.retry_started`,
`${platformCatalogMap[platform].label} 正在重新获取候选结果。`
);
this.runSearchForPlatform(task, run);
const recoveredCandidateCount = task.platformCandidates[platform].length;
task.taskStage = "confirmation";
task.taskStatus = "AwaitingConfirmation";
task.updatedAt = nowIso();
this.pushEvent(
task,
`platform.${platform}.retry_finished`,
recoveredCandidateCount > 0
? `${platformCatalogMap[platform].label} 已恢复候选确认。`
: `${platformCatalogMap[platform].label} 重试后仍未返回候选结果。`
);
return task;
}
if (run.selectedCandidateIds.length === 0) {
this.pushEvent(
task,
`platform.${platform}.retry_skipped`,
`${platformCatalogMap[platform].label} 当前没有已确认链接,无法执行定向重试。`
);
return task;
}
task.taskStatus = "Running";
task.taskStage = "session_check";
this.pushEvent(
task,
`platform.${platform}.retry_started`,
`${platformCatalogMap[platform].label} 正在执行定向重试。`
);
this.executeSelectedPlatforms(task, [run]);
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
task.updatedAt = nowIso();
this.publishReportIfNeeded(task);
return task;
}
@@ -218,38 +315,7 @@ export class InMemoryTaskStore {
task.taskStage = "search";
for (const run of task.platformRuns) {
const readiness = this.requireReadiness(run.platform);
if (readiness.searchRequirement === "required" && !readiness.ready) {
run.status = "SearchBlocked";
run.reason = platformCatalogMap[run.platform].recoveryHint;
run.candidateCount = 0;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.search_blocked`,
`${platformCatalogMap[run.platform].label} 缺少有效会话,搜索被阻塞。`
);
continue;
}
run.status = "Searching";
run.lastUpdatedAt = nowIso();
const candidates = createMockCandidates(task.query, run.platform);
task.platformCandidates[run.platform] = candidates;
run.candidateCount = candidates.length;
run.status = candidates.length > 0 ? "AwaitingSelection" : "NoResult";
run.reason =
candidates.length > 0
? undefined
: `${platformCatalogMap[run.platform].label} 当前未找到可供确认的候选。`;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.searched`,
candidates.length > 0
? `${platformCatalogMap[run.platform].label} 返回 ${candidates.length} 个候选。`
: `${platformCatalogMap[run.platform].label} 未返回候选结果。`
);
this.runSearchForPlatform(task, run);
}
task.taskStage = "confirmation";
@@ -317,13 +383,13 @@ export class InMemoryTaskStore {
: "已基于当前可用平台生成首版结构化报告。",
key_points: [
`已确认 ${selectedCandidates.length} 个商品链接,纳入 ${reviewSampleCount} 条评论样本。`,
blockedPlatforms.length > 0
? `仍有 ${blockedPlatforms.length} 个平台需要先恢复会话`
blockedPlatforms.length + failedPlatforms.length > 0
? `仍有 ${blockedPlatforms.length + failedPlatforms.length} 个平台未完成,其中 ${blockedPlatforms.length} 个阻塞、${failedPlatforms.length} 个失败`
: "当前已确认平台全部处理完成。"
],
limitations: [
blockedPlatforms.length > 0
? "被阻塞平台进入可发布洞察范围。"
blockedPlatforms.length + failedPlatforms.length > 0
? "未完成平台进入当前可发布洞察范围。"
: "当前为第一批开发样板数据,后续将替换为真实采集链路。"
]
},
@@ -378,11 +444,11 @@ export class InMemoryTaskStore {
]
: [],
negative_themes:
run.status === "SearchBlocked"
run.status === "SearchBlocked" || run.status === "Blocked" || run.status === "Failed"
? [
sharedInsight(
`${platformCatalogMap[run.platform].label} 当前未进入采集`,
run.reason ?? "当前平台需要先恢复会话。",
`${platformCatalogMap[run.platform].label} 当前未完成执行`,
run.reason ?? "当前平台需要先恢复会话或稍后重试。",
[]
)
]
@@ -411,10 +477,10 @@ export class InMemoryTaskStore {
recommendations: [
sharedInsight(
"继续补齐受阻平台会话",
blockedPlatforms.length > 0
? `建议优先处理 ${blockedPlatforms
blockedPlatforms.length + failedPlatforms.length > 0
? `建议优先处理 ${[...blockedPlatforms, ...failedPlatforms]
.map((platform) => platformCatalogMap[platform].label)
.join("、")}会话准备,再发起下一轮任务。`
.join("、")}恢复或重试,再发起下一轮任务。`
: "建议基于当前样板链路继续扩展到真实采集策略。",
evidenceIndex.map((evidence) => evidence.evidence_id)
)
@@ -430,6 +496,193 @@ export class InMemoryTaskStore {
});
}
private runSearchForPlatform(task: TaskRecord, run: PlatformRunRecord): void {
const readiness = this.requireReadiness(run.platform);
if (readiness.searchRequirement === "required" && !readiness.ready) {
run.status = "SearchBlocked";
run.reason = platformCatalogMap[run.platform].recoveryHint;
run.candidateCount = 0;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.search_blocked`,
`${platformCatalogMap[run.platform].label} 缺少有效会话,搜索被阻塞。`
);
return;
}
run.status = "Searching";
run.reason = undefined;
run.lastUpdatedAt = nowIso();
const candidates = createMockCandidates(task.query, run.platform);
task.platformCandidates[run.platform] = candidates;
run.candidateCount = candidates.length;
run.status = candidates.length > 0 ? "AwaitingSelection" : "NoResult";
run.reason =
candidates.length > 0
? undefined
: `${platformCatalogMap[run.platform].label} 当前未找到可供确认的候选。`;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.searched`,
candidates.length > 0
? `${platformCatalogMap[run.platform].label} 返回 ${candidates.length} 个候选。`
: `${platformCatalogMap[run.platform].label} 未返回候选结果。`
);
}
private executeSelectedPlatforms(
task: TaskRecord,
runs: PlatformRunRecord[]
): void {
task.taskStage = "crawl";
for (const run of runs) {
const readiness = this.requireReadiness(run.platform);
if (readiness.searchRequirement === "required" && !readiness.ready) {
run.status = "Blocked";
run.reason = platformCatalogMap[run.platform].recoveryHint;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.blocked`,
`${platformCatalogMap[run.platform].label} 抓取前校验失败,需要先恢复会话。`
);
continue;
}
const mockOutcome = this.consumeExecutionScenario(task.taskId, run.platform);
if (mockOutcome === "Blocked") {
run.status = "Blocked";
run.reason = `${platformCatalogMap[run.platform].label} 命中模拟阻塞,需要先进入恢复流程。`;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.blocked`,
`${platformCatalogMap[run.platform].label} 本轮执行被阻塞。`
);
continue;
}
run.status = "Running";
run.reason = undefined;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.running`,
`${platformCatalogMap[run.platform].label} 开始抓取商品与评论。`
);
if (mockOutcome === "Failed") {
run.status = "Failed";
run.reason = `${platformCatalogMap[run.platform].label} 命中模拟失败,可稍后发起定向重试。`;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.failed`,
`${platformCatalogMap[run.platform].label} 本轮执行失败。`
);
continue;
}
run.status = "Completed";
run.reason = undefined;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.completed`,
`${platformCatalogMap[run.platform].label} 已完成当前轮执行。`
);
}
const hasCompletedConfirmedPlatform = task.platformRuns.some(
(run) => run.selectedCandidateIds.length > 0 && run.status === "Completed"
);
if (!hasCompletedConfirmedPlatform) {
task.taskStage = "publish";
this.pushEvent(task, "task.publish_skipped", "当前没有可发布结果,暂不生成报告。");
return;
}
task.taskStage = "normalize";
this.pushEvent(task, "task.normalize", "系统正在标准化商品与评论数据。");
task.taskStage = "analyze";
this.pushEvent(task, "task.analyze", "系统正在生成结构化报告。");
task.taskStage = "publish";
}
private publishReportIfNeeded(task: TaskRecord): boolean {
if (!isReportableTaskStatus(task.taskStatus)) {
this.pushEvent(task, "task.report_skipped", "当前任务状态不满足报告发布条件。");
return false;
}
const fingerprint = this.buildReportFingerprint(task);
const previousFingerprints = this.reportFingerprints.get(task.taskId) ?? [];
const lastFingerprint = previousFingerprints[previousFingerprints.length - 1];
if (lastFingerprint === fingerprint) {
this.pushEvent(task, "task.report_unchanged", "本轮重试未改变报告结果,沿用当前版本。");
return false;
}
const report = this.buildReport(task);
const previousReports = this.reports.get(task.taskId) ?? [];
this.reports.set(task.taskId, [...previousReports, report]);
this.reportFingerprints.set(task.taskId, [...previousFingerprints, fingerprint]);
task.reportVersions = [...task.reportVersions, report.report_version];
task.defaultReportVersion = report.report_version;
task.latestSuccessfulReportVersion = report.report_version;
this.pushEvent(task, "task.report_published", `报告 v${report.report_version} 已生成。`);
return true;
}
private buildReportFingerprint(task: TaskRecord): string {
const selectedLinkCount = task.platformRuns.reduce(
(sum, run) => sum + run.selectedCandidateIds.length,
0
);
return JSON.stringify({
taskStatus: task.taskStatus,
perLinkLimit: task.perLinkLimit,
taskTotalLimit: task.taskTotalLimit,
selectedLinkCount,
platformRuns: task.platformRuns.map((run) => ({
platform: run.platform,
status: run.status,
reason: run.reason ?? null,
selectedCandidateIds: [...run.selectedCandidateIds].sort()
}))
});
}
private consumeExecutionScenario(
taskId: string,
platform: PlatformId
): MockExecutionScenario["outcome"] | null {
const taskScenarios = this.executionScenarios.get(taskId);
const scenario = taskScenarios?.[platform];
if (!scenario) {
return null;
}
if (scenario.mode === "once") {
delete taskScenarios?.[platform];
if (taskScenarios && Object.keys(taskScenarios).length === 0) {
this.executionScenarios.delete(taskId);
}
}
return scenario.outcome;
}
private pushEvent(task: TaskRecord, type: string, message: string): void {
const event: TaskEventRecord = {
eventId: randomUUID(),