From fff35a157ca72dffded6acaa816a6840c45caad7 Mon Sep 17 00:00:00 2001 From: renzhiye Date: Fri, 3 Apr 2026 13:44:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E9=81=BF=E5=85=8D=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=90=8E=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=89=A7=E8=A1=8C=E5=B7=B2=E5=AE=8C=E6=88=90=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/server.test.ts | 91 +++++++++++++++++++++++++++++++++++++ apps/api/src/store.ts | 34 ++++++++++++-- 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/apps/api/src/server.test.ts b/apps/api/src/server.test.ts index 7ae412b..d1d6116 100644 --- a/apps/api/src/server.test.ts +++ b/apps/api/src/server.test.ts @@ -317,6 +317,97 @@ describe("API server", () => { await app.close(); }); + it("does not rerun completed platforms when confirming a newly recovered platform", async () => { + const app = createServer(); + await app.ready(); + + const createdTask = await createTask(app, "iPhone 15 Pro"); + const firstCandidatesResponse = await app.inject({ + method: "GET", + url: `/api/tasks/${createdTask.taskId}/candidates` + }); + const firstCandidates = firstCandidatesResponse.json().candidates; + + const firstConfirmResponse = await app.inject({ + method: "POST", + url: `/api/tasks/${createdTask.taskId}/confirm`, + payload: { + selections: [ + { + platform: "tmall", + candidateIds: [firstCandidates.tmall[0].candidateId] + } + ] + } + }); + + expect(firstConfirmResponse.statusCode).toBe(200); + expect(firstConfirmResponse.json().task.taskStatus).toBe("Completed"); + expect(firstConfirmResponse.json().task.reportVersions).toEqual([1]); + + const attemptsAfterFirstConfirm = await app.inject({ + method: "GET", + url: `/api/tasks/${createdTask.taskId}/strategy-attempts` + }); + const firstTmallExecutionAttempts = attemptsAfterFirstConfirm + .json() + .attempts.filter( + (attempt: { platform: string; capability: string }) => + attempt.platform === "tmall" && + (attempt.capability === "detail" || attempt.capability === "reviews") + ); + expect(firstTmallExecutionAttempts).toHaveLength(2); + + await preparePlatform(app, "jd"); + const retryResponse = await app.inject({ + method: "POST", + url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry` + }); + + expect(retryResponse.statusCode).toBe(200); + + const recoveredCandidatesResponse = await app.inject({ + method: "GET", + url: `/api/tasks/${createdTask.taskId}/candidates` + }); + const recoveredCandidates = recoveredCandidatesResponse.json().candidates; + + const secondConfirmResponse = await app.inject({ + method: "POST", + url: `/api/tasks/${createdTask.taskId}/confirm`, + payload: { + selections: [ + { + platform: "jd", + candidateIds: [recoveredCandidates.jd[0].candidateId] + } + ] + } + }); + + expect(secondConfirmResponse.statusCode).toBe(200); + expect(secondConfirmResponse.json().task.taskStatus).toBe("Completed"); + expect(secondConfirmResponse.json().task.reportVersions).toEqual([1, 2]); + + const attemptsAfterSecondConfirm = await app.inject({ + method: "GET", + url: `/api/tasks/${createdTask.taskId}/strategy-attempts` + }); + const executionAttempts = attemptsAfterSecondConfirm.json().attempts.filter( + (attempt: { platform: string; capability: string }) => + attempt.capability === "detail" || attempt.capability === "reviews" + ); + + expect( + executionAttempts.filter((attempt: { platform: string }) => attempt.platform === "tmall") + ).toHaveLength(2); + expect( + executionAttempts.filter((attempt: { platform: string }) => attempt.platform === "jd") + ).toHaveLength(2); + + await app.close(); + }); + it("records recovery audit entries and retry metrics for recovered platforms", async () => { const app = createServer(); await app.ready(); diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts index 729ab68..60812ee 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -46,6 +46,16 @@ function createPlatformCandidatesRecord(): Record }; } +function hasSameCandidateSelection(current: string[], next: string[]): boolean { + if (current.length !== next.length) { + return false; + } + + const currentSorted = [...current].sort(); + const nextSorted = [...next].sort(); + return currentSorted.every((candidateId, index) => candidateId === nextSorted[index]); +} + function createPlatformRunMetricsRecord( taskId: string, timestamp: string @@ -452,9 +462,16 @@ export class InMemoryTaskStore { const selectedCandidateIds = selectionMap.get(run.platform) ?? []; if (selectedCandidateIds.length > 0) { - run.status = "Selected"; + const selectionChanged = !hasSameCandidateSelection( + run.selectedCandidateIds, + selectedCandidateIds + ); run.selectedCandidateIds = selectedCandidateIds; - run.lastUpdatedAt = nowIso(); + + if (run.status !== "Completed" || selectionChanged) { + run.status = "Selected"; + run.lastUpdatedAt = nowIso(); + } } else if (run.status === "AwaitingSelection") { run.status = "Skipped"; run.selectedCandidateIds = []; @@ -466,16 +483,25 @@ export class InMemoryTaskStore { task.updatedAt = nowIso(); this.pushEvent(task, "task.confirmed", "候选确认已提交。"); - const selectedRuns = task.platformRuns.filter( + const confirmedRuns = task.platformRuns.filter( (run) => run.selectedCandidateIds.length > 0 ); - if (selectedRuns.length === 0) { + if (confirmedRuns.length === 0) { task.taskStatus = "NoSelection"; this.pushEvent(task, "task.no_selection", "用户未确认任何商品链接,任务结束。"); return task; } + const selectedRuns = task.platformRuns.filter((run) => run.status === "Selected"); + + if (selectedRuns.length === 0) { + task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns); + task.updatedAt = nowIso(); + this.publishReportIfNeeded(task); + return task; + } + task.taskStatus = "Running"; task.taskStage = "session_check"; this.pushEvent(task, "task.running", "系统开始执行抓取前校验。");