diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 6594b0248..eeeba59d2 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -59,7 +59,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 ## 账户与充值 -1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。普通充值中心 GET 必须在发起时捕获钱包 owner 生命周期与 invalidation 版本;回包只能结算不晚于该版本的刷新,过期快照不得覆盖余额或中止更新的终态刷新。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 +1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。普通充值中心 GET 必须在发起时捕获钱包 owner 生命周期与 invalidation 版本;回包只能结算不晚于该版本的刷新,过期快照不得覆盖余额或中止更新的终态刷新。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;任务列表首次 bootstrap 的 active / terminal 任一读取瞬时失败时必须有界退避重试,成功取得全局 active ID 后再交给常规轮询,不能把首次空状态固化为停止钱包通知。公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 2. 账户充值弹窗标题统一为“购买更多泥点”,当前版本只展示泥点商品,不展示会员页签、会员商品、购买会员或升级会员入口。底层会员数据与周期刷新能力继续保留用于存量兼容和结算,会员周期限时泥点不在当前版本前台展示。 3. 泥点默认商品固定为四档:`60 泥点 / ¥6`、`180 + 90 泥点 / ¥18`、`300 + 150 泥点 / ¥30`、`680 + 340 泥点 / ¥68`。`60` 档不加赠,后三档首次购买各加赠基础泥点的 `50%`;实际展示、下单校验和支付确认仍以后端返回的充值商品配置为准。 4. 首充加赠资格按泥点商品档位独立计算。用户买过 `points_180` 后,只影响 `points_180` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。 diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index 028b77bf8..ec42f3bcb 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -615,6 +615,104 @@ describe('ImageCanvasTaskSidebarView', () => { } }); + it('retries a failed bootstrap and starts wallet polling from the recovered active task', async () => { + vi.useFakeTimers(); + try { + const onExternalTaskWalletMayHaveChanged = vi.fn(); + const runningTask = createExternalTask({ + jobId: 'bootstrap-recovered-task', + status: 'running', + }); + let activeRequestCount = 0; + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => { + if (options.statuses?.includes('running')) { + activeRequestCount += 1; + if (activeRequestCount === 1) { + return Promise.reject(new Error('temporary bootstrap failure')); + } + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 1, + unacknowledgedTerminalCount: 0, + updatedAtMicros: activeRequestCount, + }, + tasks: [runningTask], + }); + } + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: 0, + updatedAtMicros: activeRequestCount, + }, + tasks: [], + }); + }, + ); + + render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2); + expect(activeRequestCount).toBe(3); + } finally { + vi.useRealTimers(); + } + }); + + it('stops bootstrap retries after the bounded retry schedule is exhausted', async () => { + vi.useFakeTimers(); + try { + listExternalGenerationTasksMock.mockRejectedValue( + new Error('task list unavailable'), + ); + + render( + , + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + await Promise.resolve(); + }); + + expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(8); + } finally { + vi.useRealTimers(); + } + }); + it('loads more completed tasks while scrolling and keeps the request capped', async () => { const completedTasks = Array.from({ length: 120 }, (_, index) => { const completedAt = new Date(Date.now() - index * 1000).toISOString(); diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx index 07be2f2e9..f985283d8 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -26,6 +26,7 @@ const COMPLETED_TASK_LIST_LIMIT = 20; const COMPLETED_TASK_LIST_MAX_LIMIT = 100; const ACTIVE_TASK_LIST_LIMIT = 100; const ACTIVE_TASK_POLL_INTERVAL_MS = 4000; +const TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS = [1000, 2000, 4000] as const; type TaskSidebarTab = 'active' | 'completed'; @@ -357,58 +358,75 @@ export function ImageCanvasTaskSidebarView({ useEffect(() => { let disposed = false; - const controller = new AbortController(); - Promise.all([ - listExternalGenerationTasks({ - limit: ACTIVE_TASK_LIST_LIMIT, - includeAcknowledgedTerminal: false, - statuses: ['running', 'queued'], - signal: controller.signal, - }), - listExternalGenerationTasks({ - limit: completedListLimit, - includeAcknowledgedTerminal: true, - statuses: ['completed', 'failed'], - signal: controller.signal, - }), - ]) - .then(([activeResponse, completedResponse]) => { - if (disposed) { - return; - } - const activeTasks = activeResponse.tasks.filter(isActiveExternalTask); - const visibleActiveTasks = filterVisibleExternalTasks(activeTasks); - const visibleCompletedTasks = filterVisibleExternalTasks( - completedResponse.tasks, - ); - setWalletActiveExternalTaskIds(activeTasks.map((task) => task.jobId)); - if ( - activeTasks.length > 0 || - completedResponse.tasks.some( - (task) => - isTerminalExternalTask(task) && !task.notificationAcknowledgedAt, - ) - ) { - onExternalTaskWalletMayHaveChanged?.(); - } - notifyCompletedExternalTasks(visibleCompletedTasks, { - unacknowledgedOnly: true, + let controller: AbortController | null = null; + let retryTimerId: number | null = null; + let retryIndex = 0; + const loadTaskLists = () => { + controller = new AbortController(); + Promise.all([ + listExternalGenerationTasks({ + limit: ACTIVE_TASK_LIST_LIMIT, + includeAcknowledgedTerminal: false, + statuses: ['running', 'queued'], + signal: controller.signal, + }), + listExternalGenerationTasks({ + limit: completedListLimit, + includeAcknowledgedTerminal: true, + statuses: ['completed', 'failed'], + signal: controller.signal, + }), + ]) + .then(([activeResponse, completedResponse]) => { + if (disposed) { + return; + } + const activeTasks = activeResponse.tasks.filter(isActiveExternalTask); + const visibleActiveTasks = filterVisibleExternalTasks(activeTasks); + const visibleCompletedTasks = filterVisibleExternalTasks( + completedResponse.tasks, + ); + setWalletActiveExternalTaskIds(activeTasks.map((task) => task.jobId)); + if ( + activeTasks.length > 0 || + completedResponse.tasks.some( + (task) => + isTerminalExternalTask(task) && + !task.notificationAcknowledgedAt, + ) + ) { + onExternalTaskWalletMayHaveChanged?.(); + } + notifyCompletedExternalTasks(visibleCompletedTasks, { + unacknowledgedOnly: true, + }); + setExternalTasks( + trimStoredExternalTasks( + mergeExternalTasks(visibleActiveTasks, visibleCompletedTasks), + completedListLimit, + ), + ); + }) + .catch(() => { + if ( + disposed || + retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length + ) { + return; + } + const retryDelayMs = TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS[retryIndex]; + retryIndex += 1; + retryTimerId = window.setTimeout(loadTaskLists, retryDelayMs); }); - setExternalTasks( - trimStoredExternalTasks( - mergeExternalTasks(visibleActiveTasks, visibleCompletedTasks), - completedListLimit, - ), - ); - }) - .catch(() => { - if (!disposed) { - setExternalTasks([]); - } - }); + }; + + loadTaskLists(); return () => { disposed = true; - controller.abort(); + controller?.abort(); + if (retryTimerId !== null) { + window.clearTimeout(retryTimerId); + } }; }, [ completedListLimit,