From 1f3d3f1aa15a37b40652ebe1a67dd0453e4f2dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 19:25:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=BB=E7=AB=99=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E7=94=9F=E6=88=90=E4=BB=BB=E5=8A=A1=E6=B3=A5=E7=82=B9?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E6=97=B6=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用主站任务轮询推动共享钱包余额收敛 覆盖运行中扣费与失败退款后的余额刷新 支持其他项目任务驱动账号级钱包更新 补充主站外部生成任务回归测试 同步更新项目基线与共享决策记录 --- .../shared-memory/decision-log.md | 1 + ...�项目基线】当前产品与工程约束-2026-05-15.md | 2 +- .../image-editor/ImageCanvasEditorView.tsx | 8 +- .../image-editor/ImageCanvasStageView.tsx | 5 +- .../ImageCanvasTaskSidebarView.test.tsx | 141 ++++++++++++++++++ .../ImageCanvasTaskSidebarView.tsx | 36 ++++- 6 files changed, 179 insertions(+), 14 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a67fbc6b5..8f6970d15 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5940,5 +5940,6 @@ - UI 与 mutation:充值 controller 继续保存商品、订单和支付状态,但充值中心响应必须同步写入共享快照,余额 mutation 应在应用响应后再通知一次合并刷新。充值弹窗的余额和分桶由当前 Store 快照覆盖。生成完成、失败退款、兑换码和邀请奖励等事件只发送余额可能变化通知;账号变化同时清理旧充值中心、账单与支付临时状态。`limitedPoints` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。 - 2026-08-05 审查补充:主站 transport adapter 必须通过既有请求 options 真实透传 `AbortSignal`;页面恢复时相邻的 `visibilitychange / focus` 合并为一次余额通知,并在卸载时清理待执行任务。Store 的 owner 输入统一在边界 trim,活动请求清理同时观察 Promise 成功与失败,不能用无人接收的 `finally` 派生 Promise。 - 2026-08-05 账号隔离补充:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`。 +- 2026-08-05 生成扣退费时序补充:external generation 入队时尚未扣费,worker 领取为 `running` 后的资产操作才预扣,业务失败则先退款再写任务失败态。主站钱包因此以账号下全局 active external task 为轮询生命周期,每轮成功状态读取都通知共享 Store 合并刷新,终态轮同时覆盖成功结算与失败退款;画布内容刷新仍只限当前项目的 `completed`,不因其它项目或 `failed` 刷新画布。 - 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 75be4392b..311de537d 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 不匹配的快照,旧账号请求不得阻塞新账号首次读取。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 +1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 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/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index faeabcd8f..5374624b2 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1463,13 +1463,8 @@ export function ImageCanvasEditorView({ if (warning) { showGenerationWarning(warning); } - refreshEditorWalletState(); }, - [ - projectId, - refreshEditorWalletState, - showGenerationWarning, - ], + [projectId, showGenerationWarning], ); const effectiveIsAgentConversationOpen = isAgentConversationEnabled && isAgentConversationOpen; @@ -2374,6 +2369,7 @@ export function ImageCanvasEditorView({ onActivateGenerationDialog: activateCanvasGenerationDialog, onFocusExternalTask: focusExternalGenerationTask, onExternalTasksCompleted: handleExternalGenerationTasksCompleted, + onExternalTaskWalletMayHaveChanged: refreshEditorWalletState, onEditorAgentConfirmSent: handleEditorAgentConfirmSent, onToggleTaskSidebar: toggleTaskSidebar, onToggleAgentConversation: toggleAgentConversation, diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 1a9f6ae1b..cbafaf6ae 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -134,6 +134,7 @@ export type ImageCanvasStageViewProps = { onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void; onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void; + onExternalTaskWalletMayHaveChanged?: () => void; onEditorAgentConfirmSent?: () => void; onToggleTaskSidebar: () => void; onToggleAgentConversation: () => void; @@ -270,6 +271,7 @@ export function ImageCanvasStageView({ onActivateGenerationDialog, onFocusExternalTask, onExternalTasksCompleted, + onExternalTaskWalletMayHaveChanged, onEditorAgentConfirmSent, onToggleTaskSidebar, onToggleAgentConversation, @@ -397,7 +399,7 @@ export function ImageCanvasStageView({ selectedToolbarStyle={selectedToolbarStyle} isSplittingIconSpritesheet={Boolean( selectedLayer && - splittingIconSpritesheetLayerIds.has(selectedLayer.id), + splittingIconSpritesheetLayerIds.has(selectedLayer.id), )} isPersistingAssetKind={Boolean( selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id), @@ -502,6 +504,7 @@ export function ImageCanvasStageView({ onToggleOpen={onToggleTaskSidebar} onFocusExternalTask={onFocusExternalTask} onExternalTasksCompleted={onExternalTasksCompleted} + onExternalTaskWalletMayHaveChanged={onExternalTaskWalletMayHaveChanged} /> {isAgentConversationEnabled ? ( diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index 7808a5f4b..028b77bf8 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -474,6 +474,147 @@ describe('ImageCanvasTaskSidebarView', () => { } }); + it('refreshes the wallet while an external task is running and after a failed refund settles', async () => { + vi.useFakeTimers(); + try { + const onExternalTasksCompleted = vi.fn(); + const onExternalTaskWalletMayHaveChanged = vi.fn(); + let activeRequestCount = 0; + const runningTask = createExternalTask({ + jobId: 'wallet-running-task', + requestLabel: '长耗时图片生成', + status: 'running', + priceMudPoints: 20, + }); + const failedTask = createExternalTask({ + ...runningTask, + status: 'failed', + progress: 0, + phaseDetail: '生成失败。', + error: '生成失败,泥点已退回。', + completedAt: new Date().toISOString(), + }); + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => { + if (options.statuses?.includes('running')) { + activeRequestCount += 1; + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: activeRequestCount === 1 ? 1 : 0, + unacknowledgedTerminalCount: activeRequestCount === 1 ? 0 : 1, + updatedAtMicros: activeRequestCount, + }, + tasks: activeRequestCount === 1 ? [runningTask] : [], + }); + } + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: activeRequestCount > 1 ? 1 : 0, + updatedAtMicros: activeRequestCount, + }, + tasks: activeRequestCount > 1 ? [failedTask] : [], + }); + }, + ); + + render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + await Promise.resolve(); + }); + + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2); + expect(onExternalTasksCompleted).not.toHaveBeenCalled(); + expect(refreshCanvasMock).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps wallet polling global when the active external task belongs to another project', async () => { + vi.useFakeTimers(); + try { + const onExternalTaskWalletMayHaveChanged = vi.fn(); + let activeRequestCount = 0; + const otherProjectTask = createExternalTask({ + jobId: 'other-project-wallet-task', + sourceEntityId: 'project-other', + status: 'running', + }); + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => { + if (options.statuses?.includes('running')) { + activeRequestCount += 1; + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: activeRequestCount === 1 ? 1 : 0, + unacknowledgedTerminalCount: 0, + updatedAtMicros: activeRequestCount, + }, + tasks: activeRequestCount === 1 ? [otherProjectTask] : [], + }); + } + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: 0, + updatedAtMicros: activeRequestCount, + }, + tasks: [], + }); + }, + ); + + render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.queryByText('发光猫咪主视觉')).toBeNull(); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2); + } 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 d987ce431..07be2f2e9 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -49,6 +49,7 @@ type ImageCanvasTaskSidebarViewProps = { onToggleOpen: () => void; onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void; + onExternalTaskWalletMayHaveChanged?: () => void; }; function parseTaskTimeMs(value?: string | null) { @@ -280,6 +281,7 @@ export function ImageCanvasTaskSidebarView({ onToggleOpen, onFocusExternalTask, onExternalTasksCompleted, + onExternalTaskWalletMayHaveChanged, }: ImageCanvasTaskSidebarViewProps) { const { refreshCanvas } = useImageCanvasActions(); const projectId = useImageCanvasContextStore((state) => state.projectId); @@ -289,6 +291,8 @@ export function ImageCanvasTaskSidebarView({ const [externalTasks, setExternalTasks] = useState< ExternalGenerationTaskRecord[] >([]); + const [walletActiveExternalTaskIds, setWalletActiveExternalTaskIds] = + useState([]); const [completedListState, setCompletedListState] = useState(() => ({ limit: COMPLETED_TASK_LIST_LIMIT, projectId: normalizedProjectId, @@ -372,12 +376,21 @@ export function ImageCanvasTaskSidebarView({ if (disposed) { return; } - const visibleActiveTasks = filterVisibleExternalTasks( - activeResponse.tasks, - ); + 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, }); @@ -401,6 +414,7 @@ export function ImageCanvasTaskSidebarView({ completedListLimit, filterVisibleExternalTasks, notifyCompletedExternalTasks, + onExternalTaskWalletMayHaveChanged, refreshKey, ]); @@ -412,13 +426,14 @@ export function ImageCanvasTaskSidebarView({ [externalTasks], ); const activeExternalTaskKey = activeExternalTaskIds.join('|'); + const walletActiveExternalTaskKey = walletActiveExternalTaskIds.join('|'); useEffect(() => { activeExternalTaskIdsRef.current = new Set(activeExternalTaskIds); }, [activeExternalTaskKey, activeExternalTaskIds]); useEffect(() => { - if (!activeExternalTaskIds.length) { + if (!walletActiveExternalTaskKey) { return undefined; } let disposed = false; @@ -448,6 +463,15 @@ export function ImageCanvasTaskSidebarView({ filterVisibleExternalTasks(activeResponse.tasks), filterVisibleExternalTasks(completedResponse.tasks), ); + setWalletActiveExternalTaskIds( + activeResponse.tasks + .filter(isActiveExternalTask) + .map((task) => task.jobId), + ); + // external job 在 worker 领取后才预扣泥点,running 可能先于扣费落账。 + // 只要仍有全局 active task,每轮成功轮询都推动钱包收敛;终态这一轮 + // 同时覆盖成功结算或失败退款。共享 Store 会合并并发刷新。 + onExternalTaskWalletMayHaveChanged?.(); notifyCompletedExternalTasks(refreshedTasks, { activeTaskIds: activeExternalTaskIdsRef.current, }); @@ -483,11 +507,11 @@ export function ImageCanvasTaskSidebarView({ } }; }, [ - activeExternalTaskIds, - activeExternalTaskKey, completedListLimit, filterVisibleExternalTasks, notifyCompletedExternalTasks, + onExternalTaskWalletMayHaveChanged, + walletActiveExternalTaskKey, ]); const handleTaskListScroll = useCallback(