修复任务列表首次加载失败后停止轮询
为任务列表 bootstrap 增加一秒、两秒和四秒的有界退避重试 瞬时失败时保留已有任务并在恢复 active ID 后启动钱包轮询 覆盖重试恢复与重试耗尽边界 同步更新账户钱包轮询基线
This commit is contained in:
@@ -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` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。
|
||||
|
||||
@@ -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<typeof listExternalGenerationTasks>[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(
|
||||
<ImageCanvasTaskSidebarView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusExternalTask={vi.fn()}
|
||||
onExternalTaskWalletMayHaveChanged={
|
||||
onExternalTaskWalletMayHaveChanged
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ImageCanvasTaskSidebarView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusExternalTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user