refactor(publish): collapse pending_review/unknown task states into failed
Treat ambiguous terminal states as failures end-to-end: server normalizes unknown desktop task completions and views to failed, drops the pending_review aggregate branch, and remaps pending_review to failed in publish record conversion. admin-web removes the pending_review status option from article list filters, pushes pending_review through the failed tone, and folds lingering publishing/pending entries under the publishing bucket. desktop-client relabels unknown as 发送失败 in PublishManagement/TasksView, rewrites the scaffold adapter result to a proper failed error envelope, and sanitises legacy scaffold-only error messages to a user-friendly note. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ interface PublishPlatformEntry {
|
|||||||
status: PublishRecordStatus;
|
status: PublishRecordStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublishRecordStatus = "success" | "failed" | "publishing" | "pending_review";
|
type PublishRecordStatus = "success" | "failed" | "publishing";
|
||||||
|
|
||||||
interface PublishPlatformSection {
|
interface PublishPlatformSection {
|
||||||
key: PublishRecordStatus;
|
key: PublishRecordStatus;
|
||||||
@@ -104,7 +104,7 @@ const failedEntries = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const pendingEntries = computed(() =>
|
const pendingEntries = computed(() =>
|
||||||
platformEntries.value.filter((entry) => entry.status === "publishing" || entry.status === "pending_review"),
|
platformEntries.value.filter((entry) => entry.status === "publishing"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const displayStatus = computed(() => {
|
const displayStatus = computed(() => {
|
||||||
@@ -121,7 +121,7 @@ const displayStatus = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pendingEntries.value.length > 0) {
|
if (pendingEntries.value.length > 0) {
|
||||||
return "pending_review";
|
return "publishing";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.status === "partial_success") {
|
if (props.status === "partial_success") {
|
||||||
@@ -136,6 +136,10 @@ const displayStatus = computed(() => {
|
|||||||
return "failed";
|
return "failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props.status === "pending_review") {
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
|
||||||
return props.status ?? null;
|
return props.status ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,8 +153,6 @@ const statusTone = computed(() => {
|
|||||||
return "failed";
|
return "failed";
|
||||||
case "publishing":
|
case "publishing":
|
||||||
return "processing";
|
return "processing";
|
||||||
case "pending_review":
|
|
||||||
return "warning";
|
|
||||||
default:
|
default:
|
||||||
return "default";
|
return "default";
|
||||||
}
|
}
|
||||||
@@ -178,13 +180,9 @@ const popoverSections = computed<PublishPlatformSection[]>(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pendingEntries.value.length > 0) {
|
if (pendingEntries.value.length > 0) {
|
||||||
const pendingStatus = pendingEntries.value.some((entry) => entry.status === "publishing")
|
|
||||||
? "publishing"
|
|
||||||
: "pending_review";
|
|
||||||
|
|
||||||
sections.push({
|
sections.push({
|
||||||
key: pendingStatus,
|
key: "publishing",
|
||||||
label: `${getPublishStatusMeta(pendingStatus).label}:`,
|
label: `${getPublishStatusMeta("publishing").label}:`,
|
||||||
tone: "pending",
|
tone: "pending",
|
||||||
entries: pendingEntries.value,
|
entries: pendingEntries.value,
|
||||||
});
|
});
|
||||||
@@ -208,7 +206,7 @@ function normalizePublishRecordStatus(status?: string | null): PublishRecordStat
|
|||||||
case "queued":
|
case "queued":
|
||||||
return "publishing";
|
return "publishing";
|
||||||
case "pending_review":
|
case "pending_review":
|
||||||
return "pending_review";
|
return "failed";
|
||||||
case "failed":
|
case "failed":
|
||||||
case "publish_failed":
|
case "publish_failed":
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ const publishStatusOptions = computed(() => [
|
|||||||
{ label: getPublishStatusMeta("success").label, value: "success" },
|
{ label: getPublishStatusMeta("success").label, value: "success" },
|
||||||
{ label: getPublishStatusMeta("partial_success").label, value: "partial_success" },
|
{ label: getPublishStatusMeta("partial_success").label, value: "partial_success" },
|
||||||
{ label: getPublishStatusMeta("failed").label, value: "failed" },
|
{ label: getPublishStatusMeta("failed").label, value: "failed" },
|
||||||
{ label: getPublishStatusMeta("pending_review").label, value: "pending_review" },
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const articleParams = computed<ArticleListParams>(() => {
|
const articleParams = computed<ArticleListParams>(() => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const publishStatusMap: Record<string, { label: string; color: string }> = {
|
|||||||
published: { label: "status.publish.published", color: "success" },
|
published: { label: "status.publish.published", color: "success" },
|
||||||
publish_success: { label: "status.publish.publish_success", color: "success" },
|
publish_success: { label: "status.publish.publish_success", color: "success" },
|
||||||
publish_failed: { label: "status.publish.publish_failed", color: "error" },
|
publish_failed: { label: "status.publish.publish_failed", color: "error" },
|
||||||
pending_review: { label: "status.publish.pending_review", color: "warning" },
|
pending_review: { label: "status.publish.failed", color: "error" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatDateTime(value?: string | null, pattern = "YYYY-MM-DD HH:mm"): string {
|
export function formatDateTime(value?: string | null, pattern = "YYYY-MM-DD HH:mm"): string {
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ const publishStatusOptions = computed(() => [
|
|||||||
{ label: getPublishStatusMeta("success").label, value: "success" },
|
{ label: getPublishStatusMeta("success").label, value: "success" },
|
||||||
{ label: getPublishStatusMeta("partial_success").label, value: "partial_success" },
|
{ label: getPublishStatusMeta("partial_success").label, value: "partial_success" },
|
||||||
{ label: getPublishStatusMeta("failed").label, value: "failed" },
|
{ label: getPublishStatusMeta("failed").label, value: "failed" },
|
||||||
{ label: getPublishStatusMeta("pending_review").label, value: "pending_review" },
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const articleColumns = computed<TableColumnsType<ArticleListItem>>(() => [
|
const articleColumns = computed<TableColumnsType<ArticleListItem>>(() => [
|
||||||
|
|||||||
@@ -227,7 +227,6 @@ const publishStatusOptions = computed(() => [
|
|||||||
{ label: getPublishStatusMeta("success").label, value: "success" },
|
{ label: getPublishStatusMeta("success").label, value: "success" },
|
||||||
{ label: getPublishStatusMeta("partial_success").label, value: "partial_success" },
|
{ label: getPublishStatusMeta("partial_success").label, value: "partial_success" },
|
||||||
{ label: getPublishStatusMeta("failed").label, value: "failed" },
|
{ label: getPublishStatusMeta("failed").label, value: "failed" },
|
||||||
{ label: getPublishStatusMeta("pending_review").label, value: "pending_review" },
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const articleColumns = computed<TableColumnsType<ArticleListItem>>(() => [
|
const articleColumns = computed<TableColumnsType<ArticleListItem>>(() => [
|
||||||
|
|||||||
@@ -1002,7 +1002,7 @@ function buildScaffoldResult(
|
|||||||
detail: string,
|
detail: string,
|
||||||
): AdapterExecutionResult {
|
): AdapterExecutionResult {
|
||||||
return {
|
return {
|
||||||
status: "unknown",
|
status: "failed",
|
||||||
payload: {
|
payload: {
|
||||||
...adapterPayload,
|
...adapterPayload,
|
||||||
runtime_mode: "desktop-scaffold",
|
runtime_mode: "desktop-scaffold",
|
||||||
@@ -1010,11 +1010,11 @@ function buildScaffoldResult(
|
|||||||
task_platform: task.platform,
|
task_platform: task.platform,
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
code: "desktop_task_scaffold_only",
|
code: "desktop_task_adapter_missing",
|
||||||
message: "desktop runtime adapter is scaffold-only and requires later reconcile",
|
message: "desktop runtime adapter is not implemented for this platform",
|
||||||
detail,
|
detail,
|
||||||
},
|
},
|
||||||
summary: `${task.title} 已被 desktop runtime 消费,但当前 adapter 仍是 scaffold,仅回写 unknown 供后续 reconcile。`,
|
summary: `${task.title} 执行失败:当前平台的 desktop 发布适配器尚未实现。`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,7 +1237,7 @@ function summaryFromTaskStatus(status: RuntimeTaskStatus): string {
|
|||||||
case "aborted":
|
case "aborted":
|
||||||
return "任务已被取消。";
|
return "任务已被取消。";
|
||||||
default:
|
default:
|
||||||
return "任务进入 unknown/reconcile 分支,等待进一步确认。";
|
return "任务执行异常,已按失败处理。";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function statusLabel(status: DesktopTaskInfo["status"]): string {
|
|||||||
in_progress: "正在发送",
|
in_progress: "正在发送",
|
||||||
succeeded: "发送成功",
|
succeeded: "发送成功",
|
||||||
failed: "发送失败",
|
failed: "发送失败",
|
||||||
unknown: "待确认",
|
unknown: "发送失败",
|
||||||
aborted: "已取消",
|
aborted: "已取消",
|
||||||
};
|
};
|
||||||
return map[status];
|
return map[status];
|
||||||
@@ -93,8 +93,26 @@ function extractString(value: JsonValue | null | undefined): string | null {
|
|||||||
return trimmed ? trimmed : null;
|
return trimmed ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function summaryForTask(task: DesktopTaskInfo): string {
|
function normalizePublishTaskStatus(status: DesktopTaskInfo["status"]): DesktopTaskInfo["status"] {
|
||||||
switch (task.status) {
|
return status === "unknown" ? "failed" : status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTaskErrorMessage(message: string | null): string | null {
|
||||||
|
const normalized = extractString(message);
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (normalized.includes("scaffold-only") || normalized.includes("requires later reconcile")) {
|
||||||
|
return "当前平台的桌面发布适配器未实现,任务已按失败处理。";
|
||||||
|
}
|
||||||
|
if (normalized.includes("adapter is not implemented for this platform")) {
|
||||||
|
return "当前平台的桌面发布适配器未实现,任务已按失败处理。";
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryForTask(status: DesktopTaskInfo["status"]): string {
|
||||||
|
switch (status) {
|
||||||
case "queued":
|
case "queued":
|
||||||
return "任务已进入队列,等前面的 PublishTasks 翻完后再继续展示历史记录。";
|
return "任务已进入队列,等前面的 PublishTasks 翻完后再继续展示历史记录。";
|
||||||
case "in_progress":
|
case "in_progress":
|
||||||
@@ -107,7 +125,7 @@ function summaryForTask(task: DesktopTaskInfo): string {
|
|||||||
return "任务已被取消,如需继续可再次发送。";
|
return "任务已被取消,如需继续可再次发送。";
|
||||||
case "unknown":
|
case "unknown":
|
||||||
default:
|
default:
|
||||||
return "平台返回结果仍待确认,建议人工检查后再次发送。";
|
return "发送结果异常,已按失败处理,可检查平台侧结果后再次发送。";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +235,7 @@ const accountNameMap = computed(() => {
|
|||||||
|
|
||||||
const publishTasks = computed<PublishTaskItem[]>(() =>
|
const publishTasks = computed<PublishTaskItem[]>(() =>
|
||||||
(taskPage.value?.items ?? []).map((task) => {
|
(taskPage.value?.items ?? []).map((task) => {
|
||||||
|
const normalizedStatus = normalizePublishTaskStatus(task.status);
|
||||||
const payload = task.payload ?? {};
|
const payload = task.payload ?? {};
|
||||||
const result = task.result ?? {};
|
const result = task.result ?? {};
|
||||||
const taskError = task.error ?? {};
|
const taskError = task.error ?? {};
|
||||||
@@ -233,18 +252,19 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
|
|||||||
platform: task.platform,
|
platform: task.platform,
|
||||||
accountId: task.target_account_id,
|
accountId: task.target_account_id,
|
||||||
accountName: accountNameMap.value.get(task.target_account_id) ?? "待同步账号",
|
accountName: accountNameMap.value.get(task.target_account_id) ?? "待同步账号",
|
||||||
status: task.status,
|
status: normalizedStatus,
|
||||||
summary: summaryForTask(task),
|
summary: summaryForTask(normalizedStatus),
|
||||||
attempts: task.attempts,
|
attempts: task.attempts,
|
||||||
leaseExpiresAt: task.lease_expires_at ? parseTimestamp(task.lease_expires_at) : null,
|
leaseExpiresAt: task.lease_expires_at ? parseTimestamp(task.lease_expires_at) : null,
|
||||||
createdAt: parseTimestamp(task.created_at),
|
createdAt: parseTimestamp(task.created_at),
|
||||||
updatedAt: parseTimestamp(task.updated_at),
|
updatedAt: parseTimestamp(task.updated_at),
|
||||||
externalArticleUrl: extractString(result.external_article_url),
|
externalArticleUrl: extractString(result.external_article_url),
|
||||||
externalManageUrl: extractString(result.external_manage_url),
|
externalManageUrl: extractString(result.external_manage_url),
|
||||||
errorMessage:
|
errorMessage: normalizeTaskErrorMessage(
|
||||||
extractString(taskError.message)
|
extractString(taskError.message)
|
||||||
?? extractString(taskError.detail)
|
?? extractString(taskError.detail)
|
||||||
?? extractString(taskError.code),
|
?? extractString(taskError.code),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function translateTaskStatus(status: string) {
|
|||||||
in_progress: "执行中",
|
in_progress: "执行中",
|
||||||
succeeded: "成功",
|
succeeded: "成功",
|
||||||
failed: "失败",
|
failed: "失败",
|
||||||
unknown: "未知异常",
|
unknown: "失败",
|
||||||
aborted: "已取消",
|
aborted: "已取消",
|
||||||
};
|
};
|
||||||
return map[status?.toLowerCase()] || status;
|
return map[status?.toLowerCase()] || status;
|
||||||
@@ -53,7 +53,7 @@ const columns = computed(() => {
|
|||||||
{
|
{
|
||||||
key: "exception",
|
key: "exception",
|
||||||
title: "异常与终态 (Terminal)",
|
title: "异常与终态 (Terminal)",
|
||||||
description: "unknown、failed、succeeded 等终态或补偿态。",
|
description: "failed、succeeded、aborted 等终态任务。",
|
||||||
items: tasks.filter((item) => !["queued", "in_progress"].includes(item.status)),
|
items: tasks.filter((item) => !["queued", "in_progress"].includes(item.status)),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ func (s *DesktopTaskService) Complete(ctx context.Context, client *repository.De
|
|||||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finalStatus := normalizeDesktopTaskTerminalStatus(req.Status)
|
||||||
resultJSON, err := marshalOptionalJSON(req.Payload)
|
resultJSON, err := marshalOptionalJSON(req.Payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrBadRequest(40086, "invalid_desktop_task_payload", "payload must be serializable")
|
return nil, response.ErrBadRequest(40086, "invalid_desktop_task_payload", "payload must be serializable")
|
||||||
@@ -218,7 +219,7 @@ func (s *DesktopTaskService) Complete(ctx context.Context, client *repository.De
|
|||||||
previousTask, _ := taskRepo.GetByDesktopID(ctx, desktopID, client.WorkspaceID)
|
previousTask, _ := taskRepo.GetByDesktopID(ctx, desktopID, client.WorkspaceID)
|
||||||
previousAttemptID := activeAttemptID(previousTask)
|
previousAttemptID := activeAttemptID(previousTask)
|
||||||
|
|
||||||
task, err := taskRepo.Complete(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(req.LeaseToken)), req.Status, resultJSON, errorJSON)
|
task, err := taskRepo.Complete(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(req.LeaseToken)), finalStatus, resultJSON, errorJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, response.ErrConflict(40983, "desktop_task_lease_invalid", "desktop task lease token is invalid or expired")
|
return nil, response.ErrConflict(40983, "desktop_task_lease_invalid", "desktop task lease token is invalid or expired")
|
||||||
@@ -230,7 +231,7 @@ func (s *DesktopTaskService) Complete(ctx context.Context, client *repository.De
|
|||||||
if finishErr := taskRepo.FinishAttempt(ctx, repository.FinishDesktopTaskAttemptParams{
|
if finishErr := taskRepo.FinishAttempt(ctx, repository.FinishDesktopTaskAttemptParams{
|
||||||
TaskID: task.DesktopID,
|
TaskID: task.DesktopID,
|
||||||
AttemptID: *previousAttemptID,
|
AttemptID: *previousAttemptID,
|
||||||
FinalStatus: &req.Status,
|
FinalStatus: &finalStatus,
|
||||||
Error: errorJSON,
|
Error: errorJSON,
|
||||||
}); finishErr != nil {
|
}); finishErr != nil {
|
||||||
s.logWarn("desktop task attempt finish failed after complete", finishErr, zap.String("task_id", task.DesktopID.String()))
|
s.logWarn("desktop task attempt finish failed after complete", finishErr, zap.String("task_id", task.DesktopID.String()))
|
||||||
@@ -614,7 +615,7 @@ func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) {
|
|||||||
Platform: platform,
|
Platform: platform,
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
Payload: json.RawMessage(payload),
|
Payload: json.RawMessage(payload),
|
||||||
Status: status,
|
Status: normalizeDesktopTaskTerminalStatus(status),
|
||||||
DedupKey: dedupKeyText,
|
DedupKey: dedupKeyText,
|
||||||
ActiveAttemptID: activeAttemptIDText,
|
ActiveAttemptID: activeAttemptIDText,
|
||||||
LeaseExpiresAt: leaseExpiresAtValue,
|
LeaseExpiresAt: leaseExpiresAtValue,
|
||||||
@@ -672,7 +673,7 @@ func buildDesktopTaskView(task *repository.DesktopTask) DesktopTaskView {
|
|||||||
Platform: task.Platform,
|
Platform: task.Platform,
|
||||||
Kind: task.Kind,
|
Kind: task.Kind,
|
||||||
Payload: json.RawMessage(task.Payload),
|
Payload: json.RawMessage(task.Payload),
|
||||||
Status: task.Status,
|
Status: normalizeDesktopTaskTerminalStatus(task.Status),
|
||||||
DedupKey: task.DedupKey,
|
DedupKey: task.DedupKey,
|
||||||
ActiveAttemptID: activeAttemptID,
|
ActiveAttemptID: activeAttemptID,
|
||||||
LeaseExpiresAt: task.LeaseExpiresAt,
|
LeaseExpiresAt: task.LeaseExpiresAt,
|
||||||
@@ -702,6 +703,15 @@ func literalStringPtr(value string) *string {
|
|||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeDesktopTaskTerminalStatus(status string) string {
|
||||||
|
switch strings.TrimSpace(status) {
|
||||||
|
case "unknown":
|
||||||
|
return "failed"
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func activeAttemptID(task *repository.DesktopTask) *uuid.UUID {
|
func activeAttemptID(task *repository.DesktopTask) *uuid.UUID {
|
||||||
if task == nil || task.ActiveAttemptID == nil {
|
if task == nil || task.ActiveAttemptID == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1013,15 +1013,9 @@ func aggregatePublishStatuses(statuses []string) string {
|
|||||||
if counts["success"] == len(statuses) {
|
if counts["success"] == len(statuses) {
|
||||||
return "success"
|
return "success"
|
||||||
}
|
}
|
||||||
if counts["pending_review"] == len(statuses) {
|
if counts["failed"] > 0 && counts["success"] > 0 {
|
||||||
return "pending_review"
|
|
||||||
}
|
|
||||||
if counts["failed"] > 0 && (counts["success"] > 0 || counts["pending_review"] > 0) {
|
|
||||||
return "partial_success"
|
return "partial_success"
|
||||||
}
|
}
|
||||||
if counts["pending_review"] > 0 {
|
|
||||||
return "pending_review"
|
|
||||||
}
|
|
||||||
if counts["success"] > 0 {
|
if counts["success"] > 0 {
|
||||||
return "success"
|
return "success"
|
||||||
}
|
}
|
||||||
@@ -1036,10 +1030,8 @@ func normalizePublishStatus(status string) string {
|
|||||||
return "publishing"
|
return "publishing"
|
||||||
case "success", "published", "publish_success":
|
case "success", "published", "publish_success":
|
||||||
return "success"
|
return "success"
|
||||||
case "failed", "publish_failed":
|
case "failed", "publish_failed", "pending_review":
|
||||||
return "failed"
|
return "failed"
|
||||||
case "pending_review":
|
|
||||||
return "pending_review"
|
|
||||||
default:
|
default:
|
||||||
return "failed"
|
return "failed"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ func desktopTaskToPublishStatus(taskStatus string) string {
|
|||||||
case "in_progress":
|
case "in_progress":
|
||||||
return "publishing"
|
return "publishing"
|
||||||
case "unknown":
|
case "unknown":
|
||||||
return "pending_review"
|
return "failed"
|
||||||
case "succeeded":
|
case "succeeded":
|
||||||
return "success"
|
return "success"
|
||||||
case "failed", "aborted":
|
case "failed", "aborted":
|
||||||
|
|||||||
Reference in New Issue
Block a user