修复钱包与账号异步状态隔离

修复 legacy 余额回退与编辑器加载态
保留任务列表局部成功结果并持续钱包轮询
隔离邀请码异步状态并防止重复下单
补齐支付响应快照时序与定向回归测试
同步钱包与支付生命周期文档
This commit is contained in:
2026-08-07 13:17:44 +08:00
parent 121babbc19
commit e4d570e5d1
15 changed files with 496 additions and 75 deletions
@@ -25,6 +25,7 @@ export function useAccountWallet(currentUserId: string) {
setWalletOwner,
captureWalletBalanceSnapshot,
applyWalletBalanceSnapshot,
applyLegacyWalletBalanceSnapshot,
onWalletBalanceMayHaveChanged,
resetWalletBalance,
} = useWalletStore();
@@ -154,9 +155,11 @@ export function useAccountWallet(currentUserId: string) {
mudPointBalance: nextMudPointBalance,
...content
} = center;
void walletBalance;
if (nextMudPointBalance && walletSnapshot) {
applyWalletBalanceSnapshot(walletSnapshot, nextMudPointBalance);
} else if (walletSnapshot && Number.isFinite(walletBalance)) {
applyLegacyWalletBalanceSnapshot(walletSnapshot, walletBalance);
void onWalletBalanceMayHaveChanged();
} else {
void onWalletBalanceMayHaveChanged();
}
@@ -333,8 +336,7 @@ export function useAccountWallet(currentUserId: string) {
walletLedgerError: walletUiIsVisible ? walletLedgerError : null,
rechargeOpen: walletUiIsVisible && rechargeOpen,
rechargeModalCenter:
rechargeContent &&
visibleWalletBalance !== null
rechargeContent && visibleWalletBalance !== null
? {
...rechargeContent,
walletBalance: visibleWalletBalance,
@@ -53,6 +53,7 @@ describe('useWalletStore', () => {
expect(result.current.mudPointBalanceStatus).toBe('ready');
expect(result.current.mudPointBalanceError).toBe('');
expect(Object.keys(result.current).sort()).toEqual([
'applyLegacyWalletBalanceSnapshot',
'applyWalletBalanceSnapshot',
'captureWalletBalanceSnapshot',
'legacyWalletBalance',
@@ -266,6 +267,34 @@ describe('useWalletStore', () => {
});
});
test('keeps a successful legacy recharge total when the breakdown refresh fails', async () => {
clientApi.getClientProfileRechargeCenter.mockResolvedValueOnce({
walletBalance: 90,
mudPointBalance: detailedBalance(90),
});
const { result } = renderHook(() => useAccountWallet('user-a'));
await act(async () => undefined);
clientApi.getClientProfileRechargeCenter
.mockResolvedValueOnce({
walletBalance: 37,
membership: null,
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
})
.mockRejectedValueOnce(new Error('明细刷新失败'));
await act(async () => {
await result.current.loadRechargeCenter();
await Promise.resolve();
});
expect(result.current.legacyWalletBalance).toBe(37);
expect(result.current.mudPointBalance).toBeNull();
});
test('keeps ledger loading independent from the recharge lifecycle', async () => {
let resolveLedger!: (value: { entries: [] }) => void;
clientApi.getClientProfileRechargeCenter.mockResolvedValue({
@@ -6480,7 +6480,7 @@
- UI 与 mutation:充值 controller 继续保存商品、订单和支付状态,但充值中心响应必须同步写入共享快照,余额 mutation 应在应用响应后再通知一次合并刷新。充值弹窗的余额和分桶由当前 Store 快照覆盖。生成完成、失败退款、兑换码和邀请奖励等事件只发送余额可能变化通知;账号变化同时清理旧充值中心、账单与支付临时状态。`limitedPoints` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。
- 2026-08-05 审查补充:主站 transport adapter 必须通过既有请求 options 真实透传 `AbortSignal`;页面恢复时相邻的 `visibilitychange / focus` 合并为一次余额通知,并在卸载时清理待执行任务。Store 的 owner 输入统一在边界 trim,活动请求清理同时观察 Promise 成功与失败,不能用无人接收的 `finally` 派生 Promise。
- 2026-08-05 账号隔离补充,2026-08-07 完善 legacy 总额入口:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。
- 2026-08-07 审查收口补充:共享 Store 显式保存 owner 隔离的 `legacyWalletBalance`,确保首次生命周期读取旧响应时无需先打开充值弹窗即可展示纯总额;该字段不能生成 `mudPointBalance` 分桶。直接余额快照附带单调 operation sequence,后发操作先落地后拒绝更早快照回滚。刷新错误只向 UI 暴露稳定中文提示,不透传 transport / 后端实现文案。
- 2026-08-07 审查收口补充:共享 Store 显式保存 owner 隔离的 `legacyWalletBalance`,确保首次生命周期读取旧响应时无需先打开充值弹窗即可展示纯总额;较新的 legacy-only 响应必须原子清除旧 `mudPointBalance`,该字段不能生成分桶。直接余额快照附带单调 operation sequence;充值中心、支付确认和 watch 等异步响应都在请求发起前捕获快照,后发操作先落地后拒绝更早快照回滚。刷新错误只向 UI 暴露稳定中文提示,不透传 transport / 后端实现文案。
- 2026-08-07 lifecycle 所有权补充:主站钱包坚持全应用唯一 lifecycle,由 `AuthGate` 根认证边界绑定 ready user;现役平台壳、图片编辑器和 profile controller 只消费 Store,不重复声明 owner。根边界在账号失效、依赖切换、StrictMode effect replay 和最终卸载 cleanup 时统一 `resetWalletBalance`,中止活动请求并清除 owner、明细和 legacy 总额;禁止在子页面按相同 user ID 各自清理 module-level Store。
- 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`
@@ -9,7 +9,7 @@
- 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。
- 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=<projectId>`
- 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。
- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额读取 owner 匹配的钱包 `mudPointBalance.totalPoints`;兼容响应只有 `walletBalance`只将该 legacy 总额传给顶部收起态展示,不写回共享钱包,也不伪造不限时、每日免费或重置分桶。余额区只在真实 `mudPointBalance` 存在时展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。
- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额读取 owner 匹配的钱包 `mudPointBalance.totalPoints`;兼容响应只有 `walletBalance`读取共享钱包中 owner 隔离的 legacy 总额,不伪造不限时、每日免费或重置分桶。认证用户的钱包 owner 尚未绑定或 Store 仍为 `idle / loading` 时显示加载态,不能把未完成初始化渲染成余额不可用。余额区只在真实 `mudPointBalance` 存在时展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
- 中央画布支持背景拖拽平移、滚轮二维平移、`Ctrl / Cmd + 滚轮` 缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
@@ -59,14 +59,14 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
## 账户与充值
1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`;只有充值中心响应缺少 `mudPointBalance` 时,充值弹窗、空账单、个人中心统计卡与图片画板顶部等只展示总额的入口可以在 owner 匹配后使用同一响应的 legacy `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 为准,前端不得自行相减推算。
1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`;只有充值中心响应缺少 `mudPointBalance` 时,Store 才可按 owner 保存同一响应的 legacy `walletBalance` 作为纯总额兜底。较新的 legacy-only 响应必须原子清除旧 `mudPointBalance`,充值弹窗、空账单、个人中心统计卡与图片画板顶部可读取该总额,但不得据此伪造分桶明细。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。普通充值中心 GET 与会应用余额的异步操作必须在发起时捕获钱包 owner 生命周期invalidation 版本和 operation sequence;回包只能结算不晚于该版本的刷新,过期快照不得覆盖余额或中止更新的终态刷新。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;任务列表首次 bootstrap 的 active / terminal 任一读取瞬时失败时必须有界退避重试,任一分支成功结果都应立即保留,成功取得全局 active ID 后再交给常规轮询,不能因 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` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。
5. 前端不得用 `hasPointsRecharged` 统一隐藏所有泥点档位首充权益;该字段只表示账号是否发生过任一泥点充值。
6. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp``wechat_mp_virtual``wechat_jsapi``wechat_h5``wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝。
7. 小程序 WebView 充值使用 `wechat_mp_virtual` 调起小程序虚拟支付;微信内浏览器使用 `wechat_jsapi` 调起微信支付 JSAPI;普通 Web 使用 `wechat_native` 二维码支付,避免因移动 UA、触控能力或窄屏误入 `wechat_h5`。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。一次充值从下单、宿主 / JSAPI / H5 / Native 调起到查单确认与 watch 必须始终携带同一个 `ownerUserId + account lifecycle revision`pending / confirming order、二维码、提交状态、错误结果、成功回调以及清 token、重新登录等认证副作用在每次写入前都必须校验该令牌,账号切换或卸载后旧链路不得再影响新账号。
8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`
8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`前端下单入口还必须使用同步 operation token 防止同一 React 提交周期内重复创建订单;账号切换时,充值、账单、邀请码中心、邀请码输入、弹窗和在途读取结果都必须按账号生命周期整体失效。
9. 后台“充值商品”页继续维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。会员商品配置保留不表示当前版本开放公开购买或升级入口。
## 唯一后端路线
@@ -272,6 +272,51 @@ describe('createProfileWalletStore', () => {
});
});
test('clears a stale balance breakdown when a newer refresh only has a legacy total', async () => {
const getRechargeCenter = vi
.fn()
.mockResolvedValueOnce(center(90))
.mockResolvedValueOnce({
walletBalance: 37,
} as ProfileRechargeCenterResponse);
const store = createProfileWalletStore({ getRechargeCenter });
store.getState().setWalletOwner('user-a');
await store.getState().onWalletBalanceMayHaveChanged();
await store.getState().onWalletBalanceMayHaveChanged();
expect(store.getState()).toMatchObject({
legacyWalletBalance: 37,
mudPointBalance: null,
mudPointBalanceStatus: 'error',
mudPointBalanceError: '泥点明细读取失败',
});
});
test('applies an owner-scoped legacy mutation snapshot atomically', () => {
const store = createProfileWalletStore({ getRechargeCenter: vi.fn() });
store.getState().setWalletOwner('user-a');
const structuredSnapshot = store
.getState()
.captureWalletBalanceSnapshot('user-a');
store
.getState()
.applyWalletBalanceSnapshot(structuredSnapshot!, balance(90));
const legacySnapshot = store
.getState()
.captureWalletBalanceSnapshot('user-a');
expect(
store.getState().applyLegacyWalletBalanceSnapshot(legacySnapshot!, 37),
).toBe(true);
expect(store.getState()).toMatchObject({
legacyWalletBalance: 37,
mudPointBalance: null,
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});
});
test('rejects a snapshot after switching away from and back to the same owner', () => {
const store = createProfileWalletStore({ getRechargeCenter: vi.fn() });
store.getState().setWalletOwner('user-a');
@@ -34,6 +34,10 @@ export type ProfileWalletStore = {
snapshot: ProfileWalletBalanceSnapshot,
balance: ProfileMudPointBalance,
) => boolean;
applyLegacyWalletBalanceSnapshot: (
snapshot: ProfileWalletBalanceSnapshot,
balance: number,
) => boolean;
onWalletBalanceMayHaveChanged: () => Promise<void>;
resetWalletBalance: () => void;
};
@@ -123,6 +127,32 @@ export function createProfileWalletStore(
});
return true;
},
applyLegacyWalletBalanceSnapshot: (snapshot, balance) => {
if (
!Number.isFinite(balance) ||
get().ownerUserId !== snapshot.ownerUserId ||
ownerVersion !== snapshot.ownerVersion ||
snapshot.invalidationVersion < refreshVersion ||
snapshot.invalidationVersion < settledRefreshVersion ||
snapshot.operationSequence < latestAppliedOperationSequence
) {
return false;
}
invalidateActiveRefresh();
latestAppliedOperationSequence = Math.max(
latestAppliedOperationSequence,
snapshot.operationSequence,
);
settledRefreshVersion = snapshot.invalidationVersion;
set({
mudPointBalance: null,
legacyWalletBalance: balance,
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});
return true;
},
onWalletBalanceMayHaveChanged: () => {
refreshVersion += 1;
if (activeRefresh) {
@@ -152,7 +182,10 @@ export function createProfileWalletStore(
}
if (!center.mudPointBalance) {
if (Number.isFinite(center.walletBalance)) {
set({ legacyWalletBalance: center.walletBalance });
set({
mudPointBalance: null,
legacyWalletBalance: center.walletBalance,
});
}
throw new Error('充值中心响应缺少泥点余额');
}
@@ -673,6 +673,12 @@ describe('ImageCanvasEditorView', () => {
it('shows the owner-matched legacy wallet total without inventing breakdown rows', async () => {
const user = userEvent.setup();
usePlatformWalletStore.getState().setWalletOwner('user-1');
const walletSnapshot = usePlatformWalletStore
.getState()
.captureWalletBalanceSnapshot('user-1');
usePlatformWalletStore
.getState()
.applyLegacyWalletBalanceSnapshot(walletSnapshot!, 37);
getPlatformProfileRechargeCenterMock.mockResolvedValue({
walletBalance: 37,
});
@@ -692,7 +698,7 @@ describe('ImageCanvasEditorView', () => {
canAccessProtectedData: true,
})}
>
<ImageCanvasEditorView legacyWalletBalance={37} />
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
@@ -710,6 +716,34 @@ describe('ImageCanvasEditorView', () => {
expect(within(details).queryByText('每日免费泥点')).toBeNull();
});
it('keeps the wallet entry loading while the authenticated owner is not bound yet', async () => {
render(
<AuthUiContext.Provider
value={createAuthValue({
user: {
id: 'user-1',
publicUserCode: 'U001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
})}
>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
expect(
(await screen.findByRole('button', { name: '泥点 --' })).getAttribute(
'aria-busy',
),
).toBe('true');
});
it('opens the shared wallet breakdown and ledger from the canvas topbar', async () => {
const user = userEvent.setup();
usePlatformWalletStore.getState().setWalletOwner('user-1');
@@ -331,6 +331,9 @@ export function ImageCanvasEditorView({
const storedMudPointBalance = usePlatformWalletStore(
(state) => state.mudPointBalance,
);
const storedLegacyWalletBalance = usePlatformWalletStore(
(state) => state.legacyWalletBalance,
);
const storedMudPointBalanceStatus = usePlatformWalletStore(
(state) => state.mudPointBalanceStatus,
);
@@ -348,15 +351,19 @@ export function ImageCanvasEditorView({
const mudPointBalance = walletOwnerMatchesCurrentUser
? storedMudPointBalance
: null;
const mudPointBalanceStatus = walletOwnerMatchesCurrentUser
? storedMudPointBalanceStatus
: 'idle';
const mudPointBalanceError = walletOwnerMatchesCurrentUser
? storedMudPointBalanceError
: '';
const walletBalance =
mudPointBalance?.totalPoints ??
(walletOwnerMatchesCurrentUser ? legacyWalletBalance : null);
(walletOwnerMatchesCurrentUser
? (storedLegacyWalletBalance ?? legacyWalletBalance)
: null);
const isWalletBalanceLoading =
Boolean(currentWalletOwnerUserId) &&
(!walletOwnerMatchesCurrentUser ||
storedMudPointBalanceStatus === 'idle' ||
storedMudPointBalanceStatus === 'loading');
const editorRootRef = useRef<HTMLElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const assetListRef = useRef<HTMLDivElement | null>(null);
@@ -2455,7 +2462,7 @@ export function ImageCanvasEditorView({
layers,
walletBalance,
walletBreakdown: mudPointBalance,
isWalletBalanceLoading: mudPointBalanceStatus === 'loading',
isWalletBalanceLoading,
isWalletDetailsLoading: isLoadingRechargeCenter,
walletDetailsError: rechargeError || mudPointBalanceError || null,
currentUser: authUi?.user,
@@ -689,6 +689,63 @@ describe('ImageCanvasTaskSidebarView', () => {
}
});
it('keeps a successful active task and wallet polling when only completed tasks fail', async () => {
vi.useFakeTimers();
try {
const onExternalTaskWalletMayHaveChanged = vi.fn();
const runningTask = createExternalTask({
jobId: 'active-while-completed-fails',
requestLabel: '仍在生成的任务',
status: 'running',
});
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) => {
if (options.statuses?.includes('running')) {
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 1,
unacknowledgedTerminalCount: 0,
updatedAtMicros: 1,
},
tasks: [runningTask],
});
}
return Promise.reject(new Error('completed tasks unavailable'));
},
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onExternalTaskWalletMayHaveChanged={
onExternalTaskWalletMayHaveChanged
}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText('仍在生成的任务')).toBeTruthy();
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await Promise.resolve();
});
expect(screen.getByText('仍在生成的任务')).toBeTruthy();
expect(
onExternalTaskWalletMayHaveChanged.mock.calls.length,
).toBeGreaterThan(1);
} finally {
vi.useRealTimers();
}
});
it('stops bootstrap retries after the bounded retry schedule is exhausted', async () => {
vi.useFakeTimers();
try {
@@ -724,9 +781,7 @@ describe('ImageCanvasTaskSidebarView', () => {
status: 'running',
});
listExternalGenerationTasksMock.mockImplementation(
(
options: Parameters<typeof listExternalGenerationTasks>[0] = {},
) =>
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) =>
Promise.resolve({
overview: {
pendingCount: 0,
@@ -361,53 +361,72 @@ export function ImageCanvasTaskSidebarView({
let controller: AbortController | null = null;
let retryTimerId: number | null = null;
let retryIndex = 0;
let hasLoadedActiveTasks = false;
let hasLoadedCompletedTasks = false;
const loadTaskLists = () => {
const attemptController = new AbortController();
controller = attemptController;
Promise.all([
listExternalGenerationTasks({
limit: ACTIVE_TASK_LIST_LIMIT,
includeAcknowledgedTerminal: false,
statuses: ['running', 'queued'],
signal: attemptController.signal,
}),
listExternalGenerationTasks({
limit: completedListLimit,
includeAcknowledgedTerminal: true,
statuses: ['completed', 'failed'],
signal: attemptController.signal,
}),
])
.then(([activeResponse, completedResponse]) => {
if (disposed || attemptController.signal.aborted) {
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,
const activeTasksRequest = listExternalGenerationTasks({
limit: ACTIVE_TASK_LIST_LIMIT,
includeAcknowledgedTerminal: false,
statuses: ['running', 'queued'],
signal: attemptController.signal,
}).then((activeResponse) => {
if (disposed || attemptController.signal.aborted) {
return;
}
hasLoadedActiveTasks = true;
const activeTasks = activeResponse.tasks.filter(isActiveExternalTask);
const visibleActiveTasks = filterVisibleExternalTasks(activeTasks);
setWalletActiveExternalTaskIds(activeTasks.map((task) => task.jobId));
if (activeTasks.length > 0) {
onExternalTaskWalletMayHaveChanged?.();
}
setExternalTasks((currentTasks) =>
trimStoredExternalTasks(
mergeExternalTasks(
visibleActiveTasks,
currentTasks.filter(isTerminalExternalTask),
),
);
})
completedListLimit,
),
);
});
const completedTasksRequest = listExternalGenerationTasks({
limit: completedListLimit,
includeAcknowledgedTerminal: true,
statuses: ['completed', 'failed'],
signal: attemptController.signal,
}).then((completedResponse) => {
if (disposed || attemptController.signal.aborted) {
return;
}
hasLoadedCompletedTasks = true;
const visibleCompletedTasks = filterVisibleExternalTasks(
completedResponse.tasks,
);
if (
completedResponse.tasks.some(
(task) =>
isTerminalExternalTask(task) && !task.notificationAcknowledgedAt,
)
) {
onExternalTaskWalletMayHaveChanged?.();
}
notifyCompletedExternalTasks(visibleCompletedTasks, {
unacknowledgedOnly: true,
});
setExternalTasks((currentTasks) =>
trimStoredExternalTasks(
mergeExternalTasks(
currentTasks.filter(isActiveExternalTask),
visibleCompletedTasks,
),
completedListLimit,
),
);
});
Promise.all([activeTasksRequest, completedTasksRequest])
.catch(() => {
attemptController.abort();
if (controller === attemptController) {
@@ -417,8 +436,19 @@ export function ImageCanvasTaskSidebarView({
return;
}
if (retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length) {
setExternalTasks([]);
setWalletActiveExternalTaskIds([]);
setExternalTasks((currentTasks) =>
trimStoredExternalTasks(
currentTasks.filter(
(task) =>
(hasLoadedActiveTasks && isActiveExternalTask(task)) ||
(hasLoadedCompletedTasks && isTerminalExternalTask(task)),
),
completedListLimit,
),
);
if (!hasLoadedActiveTasks) {
setWalletActiveExternalTaskIds([]);
}
return;
}
const retryDelayMs = TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS[retryIndex];
@@ -11,8 +11,8 @@ import {
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import type { AuthUser } from '@/packages/shared/src';
import type {
AuthUser,
ProfileDashboardSummary,
ProfileMudPointBalance,
} from '@/packages/shared/src';
@@ -96,6 +96,75 @@ describe('usePlatformProfileCenterController recharge fallback', () => {
});
});
test('synchronously rejects a duplicate recharge submission before React commits state', async () => {
const pendingOrder = deferred<ReturnType<typeof nativeRechargeResponse>>();
profileClientMocks.createPlatformProfileRechargeOrder.mockReturnValue(
pendingOrder.promise,
);
usePlatformWalletStore.getState().setWalletOwner('user-a');
const { result } = renderController(userA);
act(() => {
result.current.buyRechargeProduct(rechargeProduct);
result.current.buyRechargeProduct(rechargeProduct);
});
expect(
profileClientMocks.createPlatformProfileRechargeOrder,
).toHaveBeenCalledTimes(1);
await act(async () => {
pendingOrder.resolve(nativeRechargeResponse());
await pendingOrder.promise;
});
});
test('does not let a late payment confirmation overwrite a newer wallet operation', async () => {
const pendingConfirmation = deferred<{
order: ReturnType<typeof nativeRechargeResponse>['order'];
center: ReturnType<typeof nativeRechargeResponse>['center'];
}>();
profileClientMocks.createPlatformProfileRechargeOrder.mockResolvedValue(
nativeRechargeResponse(80),
);
profileClientMocks.confirmWechatPlatformProfileRechargeOrder.mockReturnValue(
pendingConfirmation.promise,
);
profileClientMocks.getPlatformProfileRechargeCenter.mockRejectedValue(
new Error('refresh unavailable'),
);
usePlatformWalletStore.getState().setWalletOwner('user-a');
const { result } = renderController(userA);
act(() => result.current.buyRechargeProduct(rechargeProduct));
await waitFor(() => {
expect(result.current.nativeWechatPayment?.orderId).toBe('order-a');
});
act(() => result.current.confirmNativeWechatPayment());
act(() => {
const newerSnapshot = usePlatformWalletStore
.getState()
.captureWalletBalanceSnapshot('user-a');
usePlatformWalletStore
.getState()
.applyWalletBalanceSnapshot(newerSnapshot!, mudPointBalance(150));
});
await act(async () => {
pendingConfirmation.resolve({
order: {
...nativeRechargeResponse().order,
status: 'paid',
},
center: nativeRechargeResponse(100).center,
});
await pendingConfirmation.promise;
await Promise.resolve();
});
expect(usePlatformWalletStore.getState().mudPointBalance?.totalPoints).toBe(
150,
);
});
test('ignores an old owner order response after the account changes', async () => {
const pendingOrder = deferred<ReturnType<typeof nativeRechargeResponse>>();
profileClientMocks.createPlatformProfileRechargeOrder
@@ -83,4 +83,37 @@ describe('usePlatformProfileCenterController redemption lifecycle', () => {
expect(result.current.isSubmittingReferralRedeem).toBe(false);
expect(onRechargeSuccess).not.toHaveBeenCalled();
});
test('clears referral UI and ignores a late referral-center read after account change', async () => {
let resolveReferralCenter!: (value: never) => void;
profileClientMocks.getPlatformProfileReferralInviteCenter.mockImplementation(
() =>
new Promise((resolve) => {
resolveReferralCenter = resolve;
}),
);
const { result, rerender } = renderController(userA);
act(() => {
result.current.openProfilePopupPanel('redeem');
result.current.setReferralRedeemCode('USER-A-CODE');
result.current.loadReferralCenter();
});
expect(result.current.profilePopupPanel).toBe('redeem');
expect(result.current.isLoadingReferral).toBe(true);
rerender({ user: userB });
expect(result.current.profilePopupPanel).toBeNull();
expect(result.current.referralRedeemCode).toBe('');
expect(result.current.referralCenter).toBeNull();
expect(result.current.isLoadingReferral).toBe(false);
await act(async () => {
resolveReferralCenter({
inviteCode: 'USER-A',
inviteLinkPath: '/?inviteCode=USER-A',
} as never);
});
expect(result.current.referralCenter).toBeNull();
expect(result.current.isLoadingReferral).toBe(false);
});
});
@@ -424,9 +424,11 @@ export function usePlatformProfileCenterController({
useRef<WechatRechargeOrderLifecycle | null>(null);
const confirmingWechatRechargeOrderRef =
useRef<WechatRechargeOrderLifecycle | null>(null);
const rechargeSubmissionRef = useRef<symbol | null>(null);
const rechargeCenterReadRevisionRef = useRef(0);
const accountLifecycleRevisionRef = useRef(0);
const walletLedgerReadRevisionRef = useRef(0);
const referralCenterReadRevisionRef = useRef(0);
const currentUserId = currentUser?.id ?? '';
const currentUserIdRef = useRef(currentUserId);
currentUserIdRef.current = currentUserId;
@@ -523,6 +525,7 @@ export function usePlatformProfileCenterController({
rechargeCenterReadRevisionRef.current += 1;
accountLifecycleRevisionRef.current += 1;
walletLedgerReadRevisionRef.current += 1;
referralCenterReadRevisionRef.current += 1;
const pendingOrder = pendingWechatRechargeOrderRef.current;
const confirmingOrder = confirmingWechatRechargeOrderRef.current;
abortRechargeOrders(pendingOrder, confirmingOrder);
@@ -534,6 +537,7 @@ export function usePlatformProfileCenterController({
}
pendingWechatRechargeOrderRef.current = null;
confirmingWechatRechargeOrderRef.current = null;
rechargeSubmissionRef.current = null;
setRechargeCenter(null);
setIsLoadingRechargeCenter(false);
setRechargeError(null);
@@ -550,7 +554,14 @@ export function usePlatformProfileCenterController({
setIsSubmittingRewardCode(false);
setRewardCodeError(null);
setRewardCodeSuccess(null);
setProfilePopupPanel(null);
setReferralCenter(null);
setIsLoadingReferral(false);
setIsReferralCenterInitialized(false);
setReferralRedeemCode('');
setIsSubmittingReferralRedeem(false);
setReferralError(null);
setReferralSuccess(null);
}, [abortRechargeOrders, currentUserId]);
const rechargeCenterReadAbortControllerRef = useRef<AbortController | null>(
null,
@@ -560,6 +571,7 @@ export function usePlatformProfileCenterController({
return () => {
rechargeCenterReadRevisionRef.current += 1;
accountLifecycleRevisionRef.current += 1;
referralCenterReadRevisionRef.current += 1;
abortRechargeOrders(
pendingWechatRechargeOrderRef.current,
confirmingWechatRechargeOrderRef.current,
@@ -641,8 +653,8 @@ export function usePlatformProfileCenterController({
(
center: ProfileRechargeCenterResponse,
account: AccountLifecycle,
refreshWallet = false,
walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId),
refreshWallet: boolean,
walletSnapshot: ReturnType<typeof captureWalletBalanceSnapshot>,
) => {
if (
!isAccountLifecycleCurrent(account) ||
@@ -666,7 +678,6 @@ export function usePlatformProfileCenterController({
},
[
applyWalletBalanceSnapshot,
captureWalletBalanceSnapshot,
isAccountLifecycleCurrent,
onWalletBalanceMayHaveChanged,
],
@@ -782,6 +793,7 @@ export function usePlatformProfileCenterController({
setWechatRechargeOrderConfirmationState({ orderId });
setSubmittingRechargeProductId(null);
setRechargePaymentResult(null);
const walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId);
void confirmWechatRechargeOrderUntilSettled(
orderId,
createRechargeConfirmationOptions(order),
@@ -798,7 +810,9 @@ export function usePlatformProfileCenterController({
}
const result = buildRechargePaymentResultForOrder(response.order);
const isPaid = result.kind === 'success';
if (!applyRechargeCenter(response.center, account, true)) {
if (
!applyRechargeCenter(response.center, account, true, walletSnapshot)
) {
return;
}
if (
@@ -860,6 +874,7 @@ export function usePlatformProfileCenterController({
}, [
applyRechargeCenter,
captureAccountLifecycle,
captureWalletBalanceSnapshot,
createRechargeConfirmationOptions,
createRechargeOrderLifecycle,
isAccountLifecycleCurrent,
@@ -890,6 +905,9 @@ export function usePlatformProfileCenterController({
confirmingWechatRechargeOrderRef.current = order;
setWechatRechargeOrderConfirmationState({ orderId: order.orderId });
setRechargePaymentResult(null);
const walletSnapshot = captureWalletBalanceSnapshot(
order.account.ownerUserId,
);
void confirmWechatRechargeOrderUntilSettled(
order.orderId,
createRechargeConfirmationOptions(order),
@@ -903,7 +921,14 @@ export function usePlatformProfileCenterController({
}
const result = buildRechargePaymentResultForOrder(response.order);
const isPaid = result.kind === 'success';
if (!applyRechargeCenter(response.center, order.account, true)) {
if (
!applyRechargeCenter(
response.center,
order.account,
true,
walletSnapshot,
)
) {
return;
}
pendingWechatRechargeOrderRef.current = null;
@@ -934,6 +959,7 @@ export function usePlatformProfileCenterController({
return true;
}, [
applyRechargeCenter,
captureWalletBalanceSnapshot,
createRechargeConfirmationOptions,
isRechargeOrderCurrent,
isSameRechargeOrder,
@@ -989,12 +1015,15 @@ export function usePlatformProfileCenterController({
(product: ProfileRechargeProduct) => {
const account = captureAccountLifecycle();
if (
rechargeSubmissionRef.current ||
submittingRechargeProductId ||
!account.ownerUserId ||
!isAccountLifecycleCurrent(account)
) {
return;
}
const submissionToken = Symbol('profile-recharge-submission');
rechargeSubmissionRef.current = submissionToken;
const walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId);
let createdOrder: WechatRechargeOrderLifecycle | null = null;
@@ -1076,6 +1105,9 @@ export function usePlatformProfileCenterController({
title: '支付处理中',
message: '正在查询微信支付到账状态。',
});
const confirmationWalletSnapshot = captureWalletBalanceSnapshot(
account.ownerUserId,
);
void confirmWechatRechargeOrderUntilSettled(
response.order.orderId,
createRechargeConfirmationOptions(order),
@@ -1089,7 +1121,12 @@ export function usePlatformProfileCenterController({
);
const isPaid = result.kind === 'success';
if (
!applyRechargeCenter(confirmResponse.center, account, true)
!applyRechargeCenter(
confirmResponse.center,
account,
true,
confirmationWalletSnapshot,
)
) {
return;
}
@@ -1247,6 +1284,11 @@ export function usePlatformProfileCenterController({
}
setRechargeError(error instanceof Error ? error.message : '充值失败');
setSubmittingRechargeProductId(null);
})
.finally(() => {
if (rechargeSubmissionRef.current === submissionToken) {
rechargeSubmissionRef.current = null;
}
});
},
[
@@ -1281,6 +1323,9 @@ export function usePlatformProfileCenterController({
? { ...current, isConfirming: true, confirmMessage: undefined }
: current,
);
const walletSnapshot = captureWalletBalanceSnapshot(
order.account.ownerUserId,
);
void confirmWechatRechargeOrderQuickly(
nativeWechatPayment.orderId,
createRechargeConfirmationOptions(order),
@@ -1291,7 +1336,14 @@ export function usePlatformProfileCenterController({
}
const result = buildRechargePaymentResultForOrder(response.order);
const isPaid = result.kind === 'success';
if (!applyRechargeCenter(response.center, order.account, true)) {
if (
!applyRechargeCenter(
response.center,
order.account,
true,
walletSnapshot,
)
) {
return;
}
if (result.kind !== 'pending') {
@@ -1339,6 +1391,7 @@ export function usePlatformProfileCenterController({
});
}, [
applyRechargeCenter,
captureWalletBalanceSnapshot,
createRechargeConfirmationOptions,
isRechargeOrderCurrent,
isSameRechargeOrder,
@@ -1386,6 +1439,9 @@ export function usePlatformProfileCenterController({
) {
try {
assertWechatRechargeConfirmationActive(confirmationOptions);
const walletSnapshot = captureWalletBalanceSnapshot(
order.account.ownerUserId,
);
const response = await watchWechatPlatformProfileRechargeOrder(
orderId,
{
@@ -1402,7 +1458,14 @@ export function usePlatformProfileCenterController({
}
const result = buildRechargePaymentResultForOrder(response.order);
if (!applyRechargeCenter(response.center, order.account, true)) {
if (
!applyRechargeCenter(
response.center,
order.account,
true,
walletSnapshot,
)
) {
return;
}
if (result.kind === 'pending') {
@@ -1465,6 +1528,7 @@ export function usePlatformProfileCenterController({
nativeWechatPayment?.expiresAt,
nativeWechatPayment?.orderId,
applyRechargeCenter,
captureWalletBalanceSnapshot,
createRechargeConfirmationOptions,
isRechargeOrderCurrent,
isSameRechargeOrder,
@@ -1538,21 +1602,41 @@ export function usePlatformProfileCenterController({
]);
const loadReferralCenter = useCallback(() => {
const account = captureAccountLifecycle();
const revision = ++referralCenterReadRevisionRef.current;
setIsLoadingReferral(true);
setIsReferralCenterInitialized(false);
void getPlatformProfileReferralInviteCenter()
.then(setReferralCenter)
.then((center) => {
if (
revision === referralCenterReadRevisionRef.current &&
isAccountLifecycleCurrent(account)
) {
setReferralCenter(center);
}
})
.catch((error: unknown) => {
if (
revision !== referralCenterReadRevisionRef.current ||
!isAccountLifecycleCurrent(account)
) {
return;
}
setReferralCenter(null);
setReferralError(
error instanceof Error ? error.message : '读取邀请码失败',
);
})
.finally(() => {
setIsReferralCenterInitialized(true);
setIsLoadingReferral(false);
if (
revision === referralCenterReadRevisionRef.current &&
isAccountLifecycleCurrent(account)
) {
setIsReferralCenterInitialized(true);
setIsLoadingReferral(false);
}
});
}, []);
}, [captureAccountLifecycle, isAccountLifecycleCurrent]);
useEffect(() => {
if (activeTab !== 'profile' || !isAuthenticated) {