fix(media): 视频任务 - 统一轮询超时并支持失败任务人工恢复

This commit is contained in:
ddCat
2026-07-31 23:24:03 +08:00
parent ffafecd830
commit 3676a48ceb
14 changed files with 510 additions and 75 deletions

View File

@@ -1,12 +1,14 @@
import { useEffect, useState } from "react";
import { App, Descriptions, Drawer, Empty, Skeleton, Tabs, Tag, Typography } from "antd";
import { App, Button, Descriptions, Drawer, Empty, Skeleton, Tabs, Tag, Typography } from "antd";
import { RefreshCw } from "lucide-react";
import { getAdminApiLog, type ApiCallLog } from "@/services/api/auth";
import { getAdminApiLog, queryAdminApiLogTask, type ApiCallLog } from "@/services/api/auth";
export function ApiLogDetailDrawer({ logId, onClose }: { logId: string | null; onClose: () => void }) {
export function ApiLogDetailDrawer({ logId, onClose, onLogUpdated }: { logId: string | null; onClose: () => void; onLogUpdated?: (log: ApiCallLog) => void }) {
const { message } = App.useApp();
const [log, setLog] = useState<ApiCallLog | null>(null);
const [loading, setLoading] = useState(false);
const [querying, setQuerying] = useState(false);
useEffect(() => {
if (!logId) return;
let active = true;
@@ -19,14 +21,36 @@ export function ApiLogDetailDrawer({ logId, onClose }: { logId: string | null; o
return () => { active = false; };
}, [logId, message]);
const queryProviderTask = async () => {
if (!log) return;
setQuerying(true);
try {
const result = await queryAdminApiLogTask(log.id);
const refreshed = await getAdminApiLog(log.id);
setLog(refreshed.log);
onLogUpdated?.(refreshed.log);
if (result.recovered) {
window.dispatchEvent(new CustomEvent("wallet:updated"));
if (result.billingSettled) message.success("已获取上游视频,任务已恢复并完成结算");
else message.warning("已获取上游视频,任务已恢复,计费状态待核对");
} else {
message.info(`上游任务仍在处理中${result.providerStatus ? `(${result.providerStatus})` : ""}`);
}
} catch (error) {
message.error(error instanceof Error ? error.message : "查询上游任务失败");
} finally {
setQuerying(false);
}
};
return (
<Drawer title="请求详情" open={Boolean(logId)} onClose={onClose} size="min(920px, 100vw)" destroyOnHidden>
{loading ? <Skeleton active paragraph={{ rows: 12 }} /> : log ? <LogDetail log={log} /> : <Empty description="没有请求详情" />}
{loading ? <Skeleton active paragraph={{ rows: 12 }} /> : log ? <LogDetail log={log} querying={querying} onQueryProviderTask={queryProviderTask} /> : <Empty description="没有请求详情" />}
</Drawer>
);
}
function LogDetail({ log }: { log: ApiCallLog }) {
function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; querying: boolean; onQueryProviderTask: () => void }) {
const providerStatus = log.providerStatus?.toLowerCase();
const processing = ["queued", "pending", "processing", "running", "in_progress"].includes(providerStatus || "");
const failed = log.status === "failed" || ["failed", "cancelled", "expired"].includes(providerStatus || "");
@@ -49,7 +73,10 @@ function LogDetail({ log }: { log: ApiCallLog }) {
["上游地址", log.upstreamUrl || "--"],
].map(([label, children], index) => ({ key: String(index), label, children }));
const canQueryProviderTask = log.capability === "video" && log.taskStatus === "failed" && Boolean(log.taskId && log.providerRequestId);
return <div className="space-y-6">
{canQueryProviderTask ? <div className="flex justify-end"><Button icon={<RefreshCw className="size-4" />} loading={querying} onClick={onQueryProviderTask}>手动查询任务</Button></div> : null}
<Descriptions bordered size="small" column={{ xs: 1, sm: 1, md: 2 }} items={items} />
<section>
<div className="mb-2 text-sm font-semibold text-foreground/85">原始报文</div>

View File

@@ -78,7 +78,7 @@ export default function LogsPage() {
<TableSurface>
{loading && logs.length === 0 ? <AdminTableSkeleton rows={8} columns={11} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} rowSelection={{ selectedRowKeys: selectedIds, preserveSelectedRowKeys: false, onChange: (keys) => setSelectedIds(keys.map(String)) }} columns={columns} dataSource={logs} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} /> }} pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`, onChange: (nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize }) }} scroll={{ x: 1700 }} />}
</TableSurface>
<ApiLogDetailDrawer logId={detailLogId} onClose={() => setDetailLogId(null)} />
<ApiLogDetailDrawer logId={detailLogId} onClose={() => setDetailLogId(null)} onLogUpdated={(next) => setLogs((items) => items.map((item) => item.id === next.id ? next : item))} />
<Modal title={mediaPreview?.title || "媒体预览"} open={Boolean(mediaPreview)} width={880} onCancel={() => setMediaPreview(null)} footer={mediaPreview ? <Button icon={<Download className="size-4" />} onClick={() => downloadMedia(mediaPreview.url, mediaPreview.kind)}>下载原文件</Button> : null} destroyOnHidden>
{mediaPreview?.kind === "video" ? <video src={mediaPreview.url} controls playsInline preload="metadata" className="max-h-[72vh] w-full bg-black object-contain" /> : mediaPreview ? <img src={mediaPreview.url} alt={mediaPreview.title} className="max-h-[72vh] w-full bg-black object-contain" /> : null}
</Modal>

View File

@@ -9,7 +9,7 @@ import { WorkspaceState } from "@/components/layout/workspace-state";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { formatTaskKind, operationOptions, statusLabel } from "@/lib/generation-task-display";
import { cancelGenerationTask, createAgentSession, createGenerationTask, listGenerationTasks, listTaskLogs, queryGenerationTask, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog, type TaskStatus } from "@/services/api/task-center";
import { cancelGenerationTask, createAgentSession, createGenerationTask, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog, type TaskStatus } from "@/services/api/task-center";
import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -191,6 +191,30 @@ export default function TasksPage() {
}
};
const queryProviderTask = async (task: GenerationTask) => {
setActingId(task.id);
try {
const result = await queryFailedVideoProviderTask(task.id);
if (!result.recovered) {
setTaskLogs(await listTaskLogs(task.id));
message.info(`上游任务仍在处理中${result.providerStatus ? `(${result.providerStatus})` : ""}`);
return;
}
setDetailTask(result.task);
setTasks((items) => items.map((item) => (item.id === task.id ? { ...item, ...result.task } : item)));
setTaskLogs(await listTaskLogs(task.id));
await syncGenerationTaskToCanvasStore(result.task);
window.dispatchEvent(new CustomEvent("wallet:updated"));
void loadTasks(false);
if (result.billingSettled) message.success("已获取上游视频,任务已恢复并完成结算");
else message.warning("已获取上游视频,任务已恢复,计费状态待管理员核对");
} catch (error) {
message.error(error instanceof Error ? error.message : "查询上游任务失败");
} finally {
setActingId("");
}
};
const submitTask = async () => {
const values = await form.validateFields();
setCreating(true);
@@ -422,6 +446,7 @@ export default function TasksPage() {
<InfoItem label="尝试次数" value={`第 ${detailTask.attempts || 1} 次`} />
<InfoItem label="创建时间" value={formatDate(detailTask.createdAt)} />
</div>
{canQueryProviderTask(detailTask) ? <div className="flex justify-end"><Button icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void queryProviderTask(detailTask)}>手动查询任务</Button></div> : null}
{detailTask.error ? <pre className="max-h-28 overflow-auto whitespace-pre-wrap border-l-2 border-red-500 bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-950/30 dark:text-red-300">{generationErrorMessage(detailTask.error)}</pre> : null}
<TaskResultMedia value={detailTask.resultJson} taskType={detailTask.type} />
<DetailBlock title="输入" value={detailLoading ? "详情加载中..." : formatTaskJson(detailTask.inputJson)} />
@@ -453,6 +478,10 @@ export default function TasksPage() {
);
}
function canQueryProviderTask(task: GenerationTask) {
return task.status === "failed" && (task.type.startsWith("canvas_video") || task.type.startsWith("video_")) && Boolean(task.providerRequestId);
}
function reconcileTaskSummaries(current: GenerationTask[], next: GenerationTask[]) {
if (current.length !== next.length) return next;
const currentById = new Map(current.map((task) => [task.id, task]));

View File

@@ -2,6 +2,7 @@ import axios from "axios";
import type { ModelChannel } from "@/stores/use-config-store";
import type { CreditLedgerEntry } from "@/services/api/wallet";
import type { GenerationTask, TaskStatus } from "@/services/api/task-center";
const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true });
@@ -46,6 +47,7 @@ export type ApiCallLog = {
channelId: string;
channelName: string;
taskId?: string;
taskStatus?: TaskStatus;
source: string;
capability: "text" | "image" | "video" | "audio" | "";
operation?: string;
@@ -83,6 +85,13 @@ export type ApiCallLog = {
createdAt: string;
};
export type AdminProviderTaskQueryResult = {
task: GenerationTask;
providerStatus: string;
recovered: boolean;
billingSettled: boolean;
};
export type AdminAuditEvent = {
id: string;
actorUserId: string;
@@ -430,6 +439,10 @@ export function getAdminApiLog(id: string) {
return request<{ log: ApiCallLog }>(api.get(`/admin/api-logs/${encodeURIComponent(id)}`));
}
export function queryAdminApiLogTask(id: string) {
return request<AdminProviderTaskQueryResult>(api.post(`/admin/api-logs/${encodeURIComponent(id)}/query-task`));
}
export async function exportAdminApiLogs(params: AdminListParams & { ids?: string[] } = {}) {
const response = await api.get<Blob>("/admin/api-logs-export.csv", { params: { ...params, ids: params.ids?.join(",") }, responseType: "blob" });
return response.data;

View File

@@ -24,6 +24,7 @@ export type GenerationTask = {
operation?: string;
provider?: string;
model?: string;
providerRequestId?: string;
errorCode?: string;
previewUrl?: string;
previewKind?: "image" | "video";
@@ -43,6 +44,13 @@ export type GenerationTask = {
updated_at?: string;
};
export type ProviderTaskQueryResult = {
task: GenerationTask;
providerStatus: string;
recovered: boolean;
billingSettled: boolean;
};
export type AgentSession = {
id: string;
projectId?: string;
@@ -185,6 +193,10 @@ export function retryGenerationTask(id: string) {
return request<GenerationTask>(api.post(`/tasks/${encodeURIComponent(id)}/retry`));
}
export function queryFailedVideoProviderTask(id: string) {
return request<ProviderTaskQueryResult>(api.post(`/tasks/${encodeURIComponent(id)}/query-provider`));
}
export function cancelGenerationTask(id: string) {
return request<GenerationTask>(api.post(`/tasks/${encodeURIComponent(id)}/cancel`));
}