升级外部生成为正式任务列表

将 external_generation_job 扩展为含价格、确认时间、退款流水和事件审计的正式生成任务事实源

新增外部生成任务列表与确认 BFF,并让平台入口按后端任务补弹完成或失败提示

保护画布生成队列超时、快照回写和自动保存覆盖场景

将 worker 计费退款流水写入 externalGenerationJobId,并补充冷备份后 worker/controller 恢复守护
This commit is contained in:
2026-06-24 15:17:12 +08:00
parent ed6b17442a
commit 73a17193d4
66 changed files with 2416 additions and 122 deletions
@@ -69,6 +69,7 @@ describe('buildExternalGenerationQueueStatus', () => {
{
pendingCount: 7,
runningCount: 3,
unacknowledgedTerminalCount: 1,
updatedAtMicros: 1_781_222_400_000_000,
},
{
@@ -85,6 +86,8 @@ describe('buildExternalGenerationQueueStatus', () => {
currentProgress: 35,
pendingCount: 7,
runningCount: 3,
unacknowledgedTerminalCount: 1,
tasks: null,
});
});
@@ -99,6 +102,7 @@ describe('buildExternalGenerationQueueStatus', () => {
currentProgress: 42.4,
pendingCount: 2,
runningCount: 1,
unacknowledgedTerminalCount: 0,
}),
).toEqual({
statusLabel: '生成中',
@@ -107,7 +111,9 @@ describe('buildExternalGenerationQueueStatus', () => {
runningLabel: '1',
pendingCount: 2,
runningCount: 1,
unacknowledgedTerminalCount: 0,
progress: 42,
tasks: [],
shouldShow: true,
});
@@ -41,7 +41,7 @@ import type {
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
ExternalGenerationJobStatusRecord,
ExternalGenerationQueueOverview,
ExternalGenerationTaskRecord,
} from '../../../packages/shared/src/contracts/externalGeneration';
import type {
JumpHopJumpRequest,
@@ -193,7 +193,10 @@ import {
regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import { getExternalGenerationQueueOverview } from '../../services/external-generation';
import {
acknowledgeExternalGenerationTasks,
listExternalGenerationTasks,
} from '../../services/external-generation';
import {
jumpHopClient,
type JumpHopGalleryCardResponse,
@@ -720,6 +723,54 @@ function isExternalGenerationQueueStage(selectionStage: SelectionStage) {
);
}
function isUnacknowledgedExternalGenerationTerminalTask(
task: ExternalGenerationTaskRecord,
) {
return (
(task.status === 'completed' || task.status === 'failed') &&
!task.notificationAcknowledgedAt
);
}
function resolveExternalGenerationTaskCompletionDialog(
tasks: readonly ExternalGenerationTaskRecord[],
): PlatformTaskCompletionDialogState | null {
const terminalTasks = tasks.filter(
isUnacknowledgedExternalGenerationTerminalTask,
);
if (terminalTasks.length === 0) {
return null;
}
const jobIds = terminalTasks.map((task) => task.jobId);
const latestUpdatedAtMicros = terminalTasks.reduce(
(latest, task) => Math.max(latest, task.updatedAtMicros),
0,
);
const completedAtMs =
latestUpdatedAtMicros > 0
? Math.floor(latestUpdatedAtMicros / 1000)
: Date.now();
return {
key: `external-generation:${jobIds.join('|')}:${latestUpdatedAtMicros}`,
source: '生成任务',
message:
terminalTasks.length > 1
? `${terminalTasks.length} 个生成任务已结束。`
: '生成任务已结束。',
completedAtMs,
taskJobIds: jobIds,
items: terminalTasks.map((task) => ({
source: task.requestLabel.trim() || task.jobKind || task.jobId,
message:
task.status === 'failed'
? (task.error?.trim() || '生成失败。')
: '生成已完成。',
})),
};
}
type PuzzleDetailReturnTarget = {
tab: PlatformHomeTab;
};
@@ -1839,8 +1890,16 @@ export function PlatformEntryFlowShellImpl({
const [puzzleOperation, setPuzzleOperation] = useState<PuzzleAgentOperationRecord | null>(
null,
);
const [externalGenerationQueueOverview, setExternalGenerationQueueOverview] =
useState<ExternalGenerationQueueOverview | null>(null);
const [externalGenerationTasks, setExternalGenerationTasks] = useState<
ExternalGenerationTaskRecord[]
>([]);
const [externalGenerationTaskOverview, setExternalGenerationTaskOverview] =
useState<{
pendingCount: number;
runningCount: number;
unacknowledgedTerminalCount: number;
updatedAtMicros: number;
} | null>(null);
const [jumpHopQueueState, setJumpHopQueueState] =
useState<ExternalGenerationJobStatusRecord | null>(null);
const [puzzleClearQueueState, setPuzzleClearQueueState] =
@@ -4922,18 +4981,21 @@ export function PlatformEntryFlowShellImpl({
platformBootstrap.platformTab === 'profile' &&
platformBootstrap.canReadProtectedData);
const isHostNetworkOnline = useHostNetworkOnline();
const shouldPollExternalGenerationTasks =
platformBootstrap.canReadProtectedData && Boolean(authUi?.user?.id);
const externalGenerationQueueOwnerKey =
shouldShowExternalGenerationQueueStatus
shouldPollExternalGenerationTasks
? (authUi?.user?.id ?? 'anonymous')
: null;
useEffect(() => {
setExternalGenerationQueueOverview(null);
setExternalGenerationTasks([]);
setExternalGenerationTaskOverview(null);
}, [externalGenerationQueueOwnerKey]);
useEffect(() => {
if (
!shouldShowExternalGenerationQueueStatus ||
!shouldPollExternalGenerationTasks ||
!isHostNetworkOnline
) {
return;
@@ -4942,24 +5004,30 @@ export function PlatformEntryFlowShellImpl({
let disposed = false;
let controller: AbortController | null = null;
const refreshQueueOverview = () => {
const refreshTaskList = () => {
controller?.abort();
controller = new AbortController();
getExternalGenerationQueueOverview(controller.signal)
listExternalGenerationTasks({
limit: 20,
includeAcknowledgedTerminal: false,
signal: controller.signal,
})
.then((response) => {
if (!disposed) {
setExternalGenerationQueueOverview(response.overview);
setExternalGenerationTasks(response.tasks);
setExternalGenerationTaskOverview(response.overview);
}
})
.catch(() => {
if (!disposed) {
setExternalGenerationQueueOverview(null);
setExternalGenerationTasks([]);
setExternalGenerationTaskOverview(null);
}
});
};
refreshQueueOverview();
const intervalId = window.setInterval(refreshQueueOverview, 4000);
refreshTaskList();
const intervalId = window.setInterval(refreshTaskList, 4000);
return () => {
disposed = true;
@@ -4969,7 +5037,7 @@ export function PlatformEntryFlowShellImpl({
}, [
externalGenerationQueueOwnerKey,
isHostNetworkOnline,
shouldShowExternalGenerationQueueStatus,
shouldPollExternalGenerationTasks,
]);
const activeExternalGenerationJobState = useMemo(() => {
const candidates = [
@@ -4998,10 +5066,15 @@ export function PlatformEntryFlowShellImpl({
const externalGenerationQueueStatus = useMemo(
() =>
buildExternalGenerationQueueStatus(
externalGenerationQueueOverview,
externalGenerationTaskOverview,
activeExternalGenerationJobState,
externalGenerationTasks,
),
[activeExternalGenerationJobState, externalGenerationQueueOverview],
[
activeExternalGenerationJobState,
externalGenerationTaskOverview,
externalGenerationTasks,
],
);
const profileExternalGenerationQueueStatus =
platformBootstrap.canReadProtectedData &&
@@ -5235,13 +5308,24 @@ export function PlatformEntryFlowShellImpl({
woodenFishRun?.runId,
woodenFishSession?.sessionId,
]);
const externalGenerationTaskCompletionDialog =
useMemo<PlatformTaskCompletionDialogState | null>(
() => resolveExternalGenerationTaskCompletionDialog(externalGenerationTasks),
[externalGenerationTasks],
);
const currentPlatformTaskCompletionDialog =
useMemo<PlatformTaskCompletionDialogState | null>(
() => pendingPlatformTaskCompletionDialog,
[pendingPlatformTaskCompletionDialog],
() =>
pendingPlatformTaskCompletionDialog ??
externalGenerationTaskCompletionDialog,
[
externalGenerationTaskCompletionDialog,
pendingPlatformTaskCompletionDialog,
],
);
const activePlatformTaskCompletionDialog =
selectionStage === 'image-editor'
selectionStage === 'image-editor' &&
!(currentPlatformTaskCompletionDialog?.taskJobIds?.length ?? 0)
? null
: resolveActivePlatformDialog(
currentPlatformTaskCompletionDialog,
@@ -5374,6 +5458,36 @@ export function PlatformEntryFlowShellImpl({
if (dismissKey) {
setDismissedPlatformTaskCompletionDialogKey(dismissKey);
}
const taskJobIds = currentPlatformTaskCompletionDialog.taskJobIds ?? [];
if (taskJobIds.length > 0) {
const acknowledgedJobIds = new Set(taskJobIds);
setExternalGenerationTasks((current) =>
current.filter((task) => !acknowledgedJobIds.has(task.jobId)),
);
setExternalGenerationTaskOverview((current) =>
current
? {
...current,
unacknowledgedTerminalCount: Math.max(
0,
current.unacknowledgedTerminalCount - acknowledgedJobIds.size,
),
}
: current,
);
void acknowledgeExternalGenerationTasks({ jobIds: taskJobIds })
.then((response) => {
setExternalGenerationTasks((current) =>
current.filter(
(task) =>
!response.acknowledgedTasks.some(
(acknowledgedTask) => acknowledgedTask.jobId === task.jobId,
),
),
);
})
.catch(() => undefined);
}
setPendingPlatformTaskCompletionDialog(null);
}, [currentPlatformTaskCompletionDialog]);
const shouldPollPuzzleGenerationSession =
@@ -69,7 +69,37 @@ export function PlatformProfileGenerationQueueCard({
fillClassName="bg-[linear-gradient(90deg,#e47631,#ce5f2a)]"
/>
<div className="mt-3 grid grid-cols-2 gap-2">
{presentation.tasks.length > 0 ? (
<div className="mt-3 space-y-2">
{presentation.tasks.map((task) => (
<div
key={task.id}
className="rounded-[8px] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2"
>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="min-w-0 truncate text-[12px] font-black text-[var(--platform-text-strong)]">
{task.title}
</span>
<PlatformPillBadge
tone={task.tone}
size="xxs"
className="shrink-0"
>
{task.statusLabel}
</PlatformPillBadge>
</div>
<div className="mt-1 flex min-w-0 items-center justify-between gap-2 text-[11px] font-medium text-[var(--platform-text-base)]">
<span className="min-w-0 truncate">{task.detail}</span>
<span className="shrink-0 font-black text-[var(--platform-text-strong)]">
{task.priceLabel}
</span>
</div>
</div>
))}
</div>
) : null}
<div className="mt-3 grid grid-cols-3 gap-2">
<div className="platform-profile-generation-queue-card__metric">
<span>排队</span>
<strong>{presentation.pendingLabel}</strong>
@@ -78,6 +108,10 @@ export function PlatformProfileGenerationQueueCard({
<span>生成</span>
<strong>{presentation.runningLabel}</strong>
</div>
<div className="platform-profile-generation-queue-card__metric">
<span>待提示</span>
<strong>{presentation.unacknowledgedTerminalCount}</strong>
</div>
</div>
</section>
);
@@ -3,6 +3,10 @@ import { PlatformReportDialog } from '../common/PlatformReportDialog';
export type PlatformTaskCompletionDialogPayload = {
source: string;
message: string;
items?: Array<{
source: string;
message: string;
}>;
};
type PlatformTaskCompletionDialogProps = {
@@ -26,17 +30,23 @@ export function PlatformTaskCompletionDialog({
copyIdleLabel="复制内容"
fields={
completion
? [
{
label: '来源',
value: completion.source,
},
{
label: '状态',
value: completion.message,
? completion.items?.length
? completion.items.map((item, index) => ({
label: item.source || `任务 ${index + 1}`,
value: item.message,
multiline: true,
},
]
}))
: [
{
label: '来源',
value: completion.source,
},
{
label: '状态',
value: completion.message,
multiline: true,
},
]
: []
}
overlayClassName={overlayClassName}
@@ -13,6 +13,7 @@ export type PlatformTaskCompletionDialogState =
PlatformTaskCompletionDialogPayload & {
key: string;
completedAtMs: number | null;
taskJobIds?: string[];
};
export type PlatformDialogCandidate = {
@@ -68,7 +69,7 @@ export function buildPlatformTaskCompletionDialogDismissKey(
completion: PlatformTaskCompletionDialogState | null,
) {
return completion
? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}`
? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}:${completion.taskJobIds?.join(',') ?? ''}`
: null;
}
@@ -1,6 +1,7 @@
import type {
ExternalGenerationJobStatusRecord,
ExternalGenerationQueueOverview,
ExternalGenerationTaskRecord,
} from '../../../packages/shared/src/contracts/externalGeneration';
export type ExternalGenerationQueueStatus = {
@@ -8,6 +9,17 @@ export type ExternalGenerationQueueStatus = {
currentProgress?: number | null;
pendingCount?: number | null;
runningCount?: number | null;
tasks?: ExternalGenerationTaskRecord[] | null;
unacknowledgedTerminalCount?: number | null;
};
export type ExternalGenerationQueueTaskPresentation = {
id: string;
title: string;
statusLabel: string;
priceLabel: string;
detail: string;
tone: 'profile' | 'danger' | 'success';
};
export type ExternalGenerationQueuePresentation = {
@@ -17,15 +29,18 @@ export type ExternalGenerationQueuePresentation = {
runningLabel: string;
pendingCount: number;
runningCount: number;
unacknowledgedTerminalCount: number;
progress: number | null;
tasks: ExternalGenerationQueueTaskPresentation[];
shouldShow: boolean;
};
export function buildExternalGenerationQueueStatus(
overview: ExternalGenerationQueueOverview | null,
job: ExternalGenerationJobStatusRecord | null,
tasks: ExternalGenerationTaskRecord[] | null = null,
): ExternalGenerationQueueStatus | null {
if (!overview && !job) {
if (!overview && !job && !tasks?.length) {
return null;
}
@@ -34,6 +49,9 @@ export function buildExternalGenerationQueueStatus(
currentProgress: job?.progress ?? null,
pendingCount: overview?.pendingCount ?? null,
runningCount: overview?.runningCount ?? null,
unacknowledgedTerminalCount:
overview?.unacknowledgedTerminalCount ?? null,
tasks,
};
}
@@ -94,6 +112,26 @@ export function normalizeExternalGenerationQueueProgress(
return Math.max(0, Math.min(100, Math.round(value)));
}
export function buildExternalGenerationTaskPresentation(
task: ExternalGenerationTaskRecord,
): ExternalGenerationQueueTaskPresentation {
const statusLabel = resolveExternalGenerationQueueStatusLabel(task.status) ?? '待处理';
return {
id: task.jobId,
title: task.requestLabel.trim() || task.jobKind,
statusLabel,
priceLabel:
task.priceMudPoints > 0 ? `${task.priceMudPoints}泥点` : '未计费',
detail: task.error?.trim() || task.phaseDetail || task.updatedAt,
tone:
task.status === 'failed'
? 'danger'
: task.status === 'completed'
? 'success'
: 'profile',
};
}
export function buildExternalGenerationQueuePresentation(
status: ExternalGenerationQueueStatus | null | undefined,
): ExternalGenerationQueuePresentation {
@@ -103,6 +141,9 @@ export function buildExternalGenerationQueuePresentation(
const runningCount = normalizeExternalGenerationQueueCount(
status?.runningCount,
);
const unacknowledgedTerminalCount = normalizeExternalGenerationQueueCount(
status?.unacknowledgedTerminalCount,
);
const progress = normalizeExternalGenerationQueueProgress(
status?.currentProgress,
);
@@ -126,7 +167,17 @@ export function buildExternalGenerationQueuePresentation(
runningLabel: runningCount.toString(),
pendingCount,
runningCount,
unacknowledgedTerminalCount,
progress: progressValue,
shouldShow: Boolean(statusLabel || pendingCount > 0 || runningCount > 0),
tasks: (status?.tasks ?? [])
.slice(0, 5)
.map(buildExternalGenerationTaskPresentation),
shouldShow: Boolean(
statusLabel ||
pendingCount > 0 ||
runningCount > 0 ||
unacknowledgedTerminalCount > 0 ||
(status?.tasks?.length ?? 0) > 0,
),
};
}