fix: 补齐前端实测与恢复重试链路
This commit is contained in:
+166
-27
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user