修复主站外部生成任务泥点刷新时机
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Successful in 2m56s
Project CI / Native shell tests (pull_request) Failing after 5m48s

复用主站任务轮询推动共享钱包余额收敛
覆盖运行中扣费与失败退款后的余额刷新
支持其他项目任务驱动账号级钱包更新
补充主站外部生成任务回归测试
同步更新项目基线与共享决策记录
This commit is contained in:
2026-08-05 19:25:21 +08:00
parent 02e4e18068
commit 1f3d3f1aa1
6 changed files with 179 additions and 14 deletions
@@ -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`。
@@ -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` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。
@@ -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,
@@ -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 ? (
@@ -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<typeof listExternalGenerationTasks>[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(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onExternalTasksCompleted={onExternalTasksCompleted}
onExternalTaskWalletMayHaveChanged={
onExternalTaskWalletMayHaveChanged
}
/>,
);
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<typeof listExternalGenerationTasks>[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(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onExternalTaskWalletMayHaveChanged={
onExternalTaskWalletMayHaveChanged
}
/>,
);
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();
@@ -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<string[]>([]);
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(