修复主站外部生成任务泥点刷新时机
复用主站任务轮询推动共享钱包余额收敛 覆盖运行中扣费与失败退款后的余额刷新 支持其他项目任务驱动账号级钱包更新 补充主站外部生成任务回归测试 同步更新项目基线与共享决策记录
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user