feat: 补充报告评论集合与评论详情证据
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
JdDetailPreviewResult,
|
||||
JdLiveService,
|
||||
JdLiveSessionSummary,
|
||||
JdProductPreviewResult,
|
||||
JdReviewsPreviewOptions,
|
||||
JdReviewsPreviewResult,
|
||||
JdSearchPreviewResult
|
||||
} from "./platforms/jd/types";
|
||||
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: 5,
|
||||
taskTotalLimit: 10
|
||||
}
|
||||
});
|
||||
|
||||
return response.json().task;
|
||||
}
|
||||
|
||||
function createJdLiveServiceStub(
|
||||
overrides: Partial<JdLiveService> = {}
|
||||
): JdLiveService {
|
||||
let summary: JdLiveSessionSummary = {
|
||||
configured: false,
|
||||
hasCookie: false,
|
||||
searchApiTemplate: { available: false },
|
||||
detailTemplate: { available: false },
|
||||
reviewsTemplate: { available: false }
|
||||
};
|
||||
|
||||
return {
|
||||
getSessionSummary() {
|
||||
return overrides.getSessionSummary?.() ?? summary;
|
||||
},
|
||||
importSession(input) {
|
||||
if (overrides.importSession) {
|
||||
return overrides.importSession(input);
|
||||
}
|
||||
|
||||
summary = {
|
||||
configured: true,
|
||||
importedAt: "2026-04-07T10: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) }
|
||||
};
|
||||
return summary;
|
||||
},
|
||||
clearSession() {
|
||||
if (overrides.clearSession) {
|
||||
overrides.clearSession();
|
||||
return;
|
||||
}
|
||||
|
||||
summary = {
|
||||
configured: false,
|
||||
hasCookie: false,
|
||||
searchApiTemplate: { available: false },
|
||||
detailTemplate: { available: false },
|
||||
reviewsTemplate: { available: false }
|
||||
};
|
||||
},
|
||||
async previewSearch(query) {
|
||||
if (overrides.previewSearch) {
|
||||
return overrides.previewSearch(query);
|
||||
}
|
||||
|
||||
const preview: JdSearchPreviewResult = {
|
||||
query,
|
||||
source: "api",
|
||||
candidateCount: 1,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: "jd-100068388533",
|
||||
platform: "jd",
|
||||
title: "Nintendo Switch 2",
|
||||
price: 2999,
|
||||
priceLabel: "¥2999",
|
||||
storeName: "京东自营",
|
||||
productUrl: "https://item.jd.com/100068388533.html",
|
||||
imageUrl: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
|
||||
salesHint: "已售 1000+",
|
||||
specLabel: "标准版",
|
||||
highlights: ["掌机", "续航"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewDetail(skuId) {
|
||||
if (overrides.previewDetail) {
|
||||
return overrides.previewDetail(skuId);
|
||||
}
|
||||
|
||||
const preview: JdDetailPreviewResult = {
|
||||
skuId,
|
||||
source: "api",
|
||||
detail: {
|
||||
skuId,
|
||||
title: "Nintendo Switch 2",
|
||||
price: "2999.00",
|
||||
originalPrice: "3299.00",
|
||||
estimatedPrice: "2999.00",
|
||||
shopName: "京东自营",
|
||||
vendorId: null,
|
||||
categoryPath: ["游戏设备", "掌机"],
|
||||
stockState: "有货",
|
||||
mainImage: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
|
||||
averageScore: "4.9"
|
||||
}
|
||||
};
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewReviews(skuId, options) {
|
||||
if (overrides.previewReviews) {
|
||||
return overrides.previewReviews(skuId, options);
|
||||
}
|
||||
|
||||
const requestedCommentCount =
|
||||
typeof options === "number" ? options : (options?.commentCount ?? 5);
|
||||
const preview: JdReviewsPreviewResult = {
|
||||
skuId,
|
||||
source: "api",
|
||||
pagination: {
|
||||
requestedPage: typeof options === "object" ? (options?.page ?? 1) : 1,
|
||||
requestedCommentCount,
|
||||
maxPages: typeof options === "object" ? (options?.maxPages ?? 1) : 1,
|
||||
pagesFetched: 1
|
||||
},
|
||||
reviews: {
|
||||
skuId,
|
||||
total: "2",
|
||||
goodRate: "95%",
|
||||
pictureCount: "1",
|
||||
tags: [{ tagId: "tag-1", name: "续航稳定", count: "2" }],
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
content: "第一条抓取评论,重点提到运行流畅。",
|
||||
score: "5",
|
||||
creationTime: "2026-04-07 10:00:00",
|
||||
userLevelName: "PLUS"
|
||||
},
|
||||
{
|
||||
id: "comment-2",
|
||||
content: "第二条抓取评论,重点提到续航稳定。",
|
||||
score: "4",
|
||||
creationTime: "2026-04-06 18:00:00",
|
||||
userLevelName: "会员"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewProduct(skuId, options?: number | JdReviewsPreviewOptions) {
|
||||
if (overrides.previewProduct) {
|
||||
return overrides.previewProduct(skuId, options);
|
||||
}
|
||||
|
||||
const detail = await this.previewDetail(skuId);
|
||||
const reviews = await this.previewReviews(skuId, options);
|
||||
const preview: JdProductPreviewResult = {
|
||||
skuId,
|
||||
source: "api",
|
||||
detail: detail.detail,
|
||||
pagination: reviews.pagination,
|
||||
reviews: reviews.reviews
|
||||
};
|
||||
|
||||
return preview;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("report review collections", () => {
|
||||
it("publishes per-link review collections for report pages", async () => {
|
||||
const app = createServer({ jdLiveService: createJdLiveServiceStub() });
|
||||
await app.ready();
|
||||
|
||||
const importResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/platforms/jd/live-session",
|
||||
payload: {
|
||||
cookieHeader: "thor=masked; pin=masked;",
|
||||
searchApiTemplateUrl:
|
||||
"https://api.m.jd.com/?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22switch%22%7D",
|
||||
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 prepareResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/platforms/jd/prepare"
|
||||
});
|
||||
|
||||
expect(prepareResponse.statusCode).toBe(200);
|
||||
|
||||
const task = await createTask(app, "Nintendo Switch 2");
|
||||
const candidatesResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${task.taskId}/candidates`
|
||||
});
|
||||
const firstCandidateId = candidatesResponse.json().candidates.jd[0].candidateId;
|
||||
|
||||
const confirmResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/tasks/${task.taskId}/confirm`,
|
||||
payload: {
|
||||
selections: [
|
||||
{
|
||||
platform: "jd",
|
||||
candidateIds: [firstCandidateId]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
expect(confirmResponse.statusCode).toBe(200);
|
||||
expect(confirmResponse.json().task.taskStatus).toBe("Completed");
|
||||
|
||||
const reportResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${task.taskId}/report`
|
||||
});
|
||||
|
||||
expect(reportResponse.statusCode).toBe(200);
|
||||
expect(reportResponse.json().report.review_collections).toEqual([
|
||||
expect.objectContaining({
|
||||
platform: "jd",
|
||||
title: "Nintendo Switch 2",
|
||||
review_count: 2,
|
||||
product_evidence_id: expect.any(String),
|
||||
sampled_review_refs: expect.arrayContaining(["comment-1"]),
|
||||
comments: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
review_ref: "comment-1",
|
||||
content: "第一条抓取评论,重点提到运行流畅。"
|
||||
}),
|
||||
expect.objectContaining({
|
||||
review_ref: "comment-2",
|
||||
content: "第二条抓取评论,重点提到续航稳定。"
|
||||
})
|
||||
])
|
||||
})
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1074,7 +1074,26 @@ describe("API server", () => {
|
||||
expect.objectContaining({
|
||||
platform: "jd",
|
||||
source_type: "review",
|
||||
review_ref: "comment-1"
|
||||
review_ref: "comment-1",
|
||||
review_detail: expect.objectContaining({
|
||||
content: expect.any(String)
|
||||
})
|
||||
})
|
||||
])
|
||||
);
|
||||
expect(reportResponse.json().report.review_collections).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
platform: "jd",
|
||||
source_url: "https://item.jd.com/100068388533.html",
|
||||
review_count: 1,
|
||||
product_evidence_id: expect.any(String),
|
||||
comments: [
|
||||
expect.objectContaining({
|
||||
review_ref: "comment-1",
|
||||
content: expect.any(String)
|
||||
})
|
||||
]
|
||||
})
|
||||
])
|
||||
);
|
||||
@@ -1421,6 +1440,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("records recovery audit entries and retry metrics for recovered platforms", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
+175
-71
@@ -374,6 +374,73 @@ function getExecutionCommentUserLabel(comment: ExecutionReviewComment): string |
|
||||
return "userLevelName" in comment ? comment.userLevelName : comment.userNick;
|
||||
}
|
||||
|
||||
function getExecutionCommentSkuLabels(comment: ExecutionReviewComment): string[] {
|
||||
return "skuText" in comment ? comment.skuText.filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function getExecutionCommentLikeCount(comment: ExecutionReviewComment): string | null {
|
||||
return "likeCount" in comment ? comment.likeCount : null;
|
||||
}
|
||||
|
||||
function getExecutionCommentReply(comment: ExecutionReviewComment): string | null {
|
||||
return "reply" in comment ? comment.reply : null;
|
||||
}
|
||||
|
||||
function getExecutionCommentAppendContent(comment: ExecutionReviewComment): string | null {
|
||||
return "appendContent" in comment ? comment.appendContent : null;
|
||||
}
|
||||
|
||||
function getExecutionCommentPictureUrls(comment: ExecutionReviewComment): string[] {
|
||||
return "pictureUrls" in comment ? comment.pictureUrls.filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function getExecutionCommentVideoUrls(comment: ExecutionReviewComment): string[] {
|
||||
return "videoUrls" in comment ? comment.videoUrls.filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function getExecutionCommentAppendPictureUrls(comment: ExecutionReviewComment): string[] {
|
||||
return "appendPictureUrls" in comment ? comment.appendPictureUrls.filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function toReportReviewCollectionComment(
|
||||
comment: ExecutionReviewComment,
|
||||
sampleBucket: ReviewSamplingBucket | null
|
||||
): NonNullable<ReportSnapshot["review_collections"]>[number]["comments"][number] {
|
||||
return {
|
||||
review_ref: comment.id,
|
||||
sample_bucket: sampleBucket,
|
||||
content: comment.content,
|
||||
score: getExecutionCommentScore(comment),
|
||||
created_at: getExecutionCommentDate(comment),
|
||||
author_label: getExecutionCommentUserLabel(comment),
|
||||
sku_labels: getExecutionCommentSkuLabels(comment),
|
||||
like_count: getExecutionCommentLikeCount(comment),
|
||||
reply: getExecutionCommentReply(comment),
|
||||
append_content: getExecutionCommentAppendContent(comment),
|
||||
picture_urls: getExecutionCommentPictureUrls(comment),
|
||||
video_urls: getExecutionCommentVideoUrls(comment),
|
||||
append_picture_urls: getExecutionCommentAppendPictureUrls(comment)
|
||||
};
|
||||
}
|
||||
|
||||
function toReportEvidenceReviewDetail(
|
||||
comment: ExecutionReviewComment
|
||||
): NonNullable<ReportSnapshot["evidence_index"][number]["review_detail"]> {
|
||||
return {
|
||||
content: comment.content,
|
||||
score: getExecutionCommentScore(comment),
|
||||
created_at: getExecutionCommentDate(comment),
|
||||
author_label: getExecutionCommentUserLabel(comment),
|
||||
sku_labels: getExecutionCommentSkuLabels(comment),
|
||||
like_count: getExecutionCommentLikeCount(comment),
|
||||
reply: getExecutionCommentReply(comment),
|
||||
append_content: getExecutionCommentAppendContent(comment),
|
||||
picture_urls: getExecutionCommentPictureUrls(comment),
|
||||
video_urls: getExecutionCommentVideoUrls(comment),
|
||||
append_picture_urls: getExecutionCommentAppendPictureUrls(comment)
|
||||
};
|
||||
}
|
||||
|
||||
function toReviewSamplingComment(comment: ExecutionReviewComment): ReviewSamplingComment {
|
||||
return {
|
||||
id: comment.id,
|
||||
@@ -1721,7 +1788,7 @@ export class InMemoryTaskStore {
|
||||
try {
|
||||
await this.retryPlatform(taskId, platform);
|
||||
} catch {
|
||||
// retryPlatform already records recoverable failures on the task.
|
||||
// Keep the current task state; retryPlatform already records recoverable failures.
|
||||
} finally {
|
||||
this.pendingManagedSessionRetries.delete(retryKey);
|
||||
}
|
||||
@@ -1787,6 +1854,7 @@ export class InMemoryTaskStore {
|
||||
const sourcePlatforms: PlatformId[] =
|
||||
insightPlatforms.length > 0 ? insightPlatforms : ["tmall"];
|
||||
const evidenceIndex: ReportSnapshot["evidence_index"] = [];
|
||||
const reviewCollections: NonNullable<ReportSnapshot["review_collections"]> = [];
|
||||
const evidenceIdsByPlatform = new Map<PlatformId, string[]>();
|
||||
let evidenceCounter = 0;
|
||||
|
||||
@@ -1798,7 +1866,8 @@ export class InMemoryTaskStore {
|
||||
sourceType: "product" | "review",
|
||||
sourceUrl: string,
|
||||
snippet: string,
|
||||
reviewRef: string | null
|
||||
reviewRef: string | null,
|
||||
reviewDetail?: NonNullable<ReportSnapshot["evidence_index"][number]["review_detail"]>
|
||||
) => {
|
||||
const evidenceId = `evidence-${task.taskId}-${++evidenceCounter}`;
|
||||
evidenceIndex.push({
|
||||
@@ -1807,6 +1876,7 @@ export class InMemoryTaskStore {
|
||||
source_type: sourceType,
|
||||
source_url: sourceUrl,
|
||||
review_ref: reviewRef,
|
||||
...(reviewDetail ? { review_detail: reviewDetail } : {}),
|
||||
snippet,
|
||||
captured_at: nowIso()
|
||||
});
|
||||
@@ -1839,7 +1909,39 @@ export class InMemoryTaskStore {
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
|
||||
addEvidence(candidate.platform, "product", candidate.productUrl, detailSummary, null);
|
||||
const productEvidenceId = addEvidence(
|
||||
candidate.platform,
|
||||
"product",
|
||||
candidate.productUrl,
|
||||
detailSummary,
|
||||
null
|
||||
);
|
||||
const sampledBucketsByCommentId = new Map(
|
||||
sampledComments.map(({ bucket, comment }) => [comment.id, bucket] as const)
|
||||
);
|
||||
const executionComments = artifact?.reviews.comments ?? [];
|
||||
|
||||
if (artifact) {
|
||||
reviewCollections.push({
|
||||
collection_id: `review-collection-${candidate.candidateId}`,
|
||||
candidate_id: candidate.candidateId,
|
||||
product_evidence_id: productEvidenceId,
|
||||
platform: candidate.platform,
|
||||
source_url: candidate.productUrl,
|
||||
title: headline,
|
||||
store_name: storeName,
|
||||
price_label: priceLabel,
|
||||
captured_at: artifact.capturedAt,
|
||||
review_count: executionComments.length,
|
||||
sampled_review_refs: sampledComments.map(({ comment }) => comment.id),
|
||||
comments: executionComments.map((comment) =>
|
||||
toReportReviewCollectionComment(
|
||||
comment,
|
||||
sampledBucketsByCommentId.get(comment.id) ?? null
|
||||
)
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
for (const { bucket, comment } of sampledComments.slice(0, 2)) {
|
||||
const commentSummary = [
|
||||
@@ -1856,7 +1958,8 @@ export class InMemoryTaskStore {
|
||||
"review",
|
||||
candidate.productUrl,
|
||||
commentSummary,
|
||||
comment.id
|
||||
comment.id,
|
||||
toReportEvidenceReviewDetail(comment)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2097,6 +2200,7 @@ export class InMemoryTaskStore {
|
||||
)
|
||||
],
|
||||
evidence_index: evidenceIndex,
|
||||
review_collections: reviewCollections,
|
||||
quality_flags: {
|
||||
sample_insufficient: sampleInsufficient,
|
||||
partial_platform_failure:
|
||||
@@ -2785,6 +2889,73 @@ export class InMemoryTaskStore {
|
||||
return true;
|
||||
}
|
||||
|
||||
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 buildReportFingerprint(task: TaskRecord): string {
|
||||
const selectedLinkCount = task.platformRuns.reduce(
|
||||
(sum, run) => sum + run.selectedCandidateIds.length,
|
||||
@@ -3062,73 +3233,6 @@ export class InMemoryTaskStore {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task) {
|
||||
|
||||
Reference in New Issue
Block a user