diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 78b1c8dab..3fc25a6c5 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -22,6 +22,7 @@ export function useAccountWallet(currentUserId: string) { mudPointBalanceStatus, mudPointBalanceError, setWalletOwner, + captureWalletBalanceSnapshot, applyWalletBalanceSnapshot, onWalletBalanceMayHaveChanged, resetWalletBalance, @@ -136,7 +137,7 @@ export function useAccountWallet(currentUserId: string) { } function applyRechargeContent( - snapshotOwnerUserId: string, + walletSnapshot: ReturnType, center: ProfileRechargeCenterResponse, ) { const { @@ -145,8 +146,8 @@ export function useAccountWallet(currentUserId: string) { ...content } = center; void walletBalance; - if (nextMudPointBalance) { - applyWalletBalanceSnapshot(snapshotOwnerUserId, nextMudPointBalance); + if (nextMudPointBalance && walletSnapshot) { + applyWalletBalanceSnapshot(walletSnapshot, nextMudPointBalance); } else { void onWalletBalanceMayHaveChanged(); } @@ -155,6 +156,7 @@ export function useAccountWallet(currentUserId: string) { async function loadRechargeCenter() { const snapshotOwnerUserId = currentUserId; + const walletSnapshot = captureWalletBalanceSnapshot(snapshotOwnerUserId); const rechargeLifecycle = rechargeLifecycleRef.current; setRechargeLoading(true); setRechargeError(null); @@ -163,7 +165,7 @@ export function useAccountWallet(currentUserId: string) { if (rechargeLifecycleRef.current !== rechargeLifecycle) { return; } - applyRechargeContent(snapshotOwnerUserId, center); + applyRechargeContent(walletSnapshot, center); } catch (error) { if (rechargeLifecycleRef.current === rechargeLifecycle) { setRechargeError( @@ -307,9 +309,7 @@ export function useAccountWallet(currentUserId: string) { submittingRechargeProductId: walletUiIsVisible ? submittingRechargeProductId : null, - nativeRechargePayment: walletUiIsVisible - ? nativeRechargePayment - : null, + nativeRechargePayment: walletUiIsVisible ? nativeRechargePayment : null, setNativeRechargePayment, loadWalletLedger, openWalletLedger, diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index 64e0c2230..008963d36 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -53,6 +53,7 @@ describe('useWalletStore', () => { expect(result.current.mudPointBalanceError).toBe(''); expect(Object.keys(result.current).sort()).toEqual([ 'applyWalletBalanceSnapshot', + 'captureWalletBalanceSnapshot', 'mudPointBalance', 'mudPointBalanceError', 'mudPointBalanceStatus', @@ -70,9 +71,12 @@ describe('useWalletStore', () => { await useWalletStore.getState().onWalletBalanceMayHaveChanged(); const mudPointBalance = detailedBalance(120); + const snapshot = useWalletStore + .getState() + .captureWalletBalanceSnapshot('user-a'); useWalletStore .getState() - .applyWalletBalanceSnapshot('user-a', mudPointBalance); + .applyWalletBalanceSnapshot(snapshot!, mudPointBalance); expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready'); @@ -90,9 +94,12 @@ describe('useWalletStore', () => { const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged(); const mudPointBalance = detailedBalance(160); + const snapshot = useWalletStore + .getState() + .captureWalletBalanceSnapshot('user-a'); useWalletStore .getState() - .applyWalletBalanceSnapshot('user-a', mudPointBalance); + .applyWalletBalanceSnapshot(snapshot!, mudPointBalance); rejectRequest?.(new Error('过期请求失败')); await refresh; @@ -141,10 +148,12 @@ describe('useWalletStore', () => { mudPointBalance: detailedBalance(80), }); - const firstRefresh = - useWalletStore.getState().onWalletBalanceMayHaveChanged(); - const trailingRefresh = - useWalletStore.getState().onWalletBalanceMayHaveChanged(); + const firstRefresh = useWalletStore + .getState() + .onWalletBalanceMayHaveChanged(); + const trailingRefresh = useWalletStore + .getState() + .onWalletBalanceMayHaveChanged(); resolveFirst?.({ walletBalance: 120, mudPointBalance: detailedBalance(120), @@ -194,9 +203,12 @@ describe('useWalletStore', () => { }); test('clears immediately when the adapter switches wallet owners', () => { + const snapshot = useWalletStore + .getState() + .captureWalletBalanceSnapshot('user-a'); useWalletStore .getState() - .applyWalletBalanceSnapshot('user-a', detailedBalance(88)); + .applyWalletBalanceSnapshot(snapshot!, detailedBalance(88)); useWalletStore.getState().setWalletOwner('user-b'); @@ -208,9 +220,12 @@ describe('useWalletStore', () => { }); test('masks another owner snapshot before the owner-binding effect runs', () => { + const snapshot = useWalletStore + .getState() + .captureWalletBalanceSnapshot('user-a'); useWalletStore .getState() - .applyWalletBalanceSnapshot('user-a', detailedBalance(88)); + .applyWalletBalanceSnapshot(snapshot!, detailedBalance(88)); function WalletProbe() { const wallet = useAccountWallet('user-b'); diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 311de537d..6594b0248 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 合并刷新。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` 任一终态再刷新以覆盖成功结算或失败退款;公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 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/packages/shared/src/stores/createProfileWalletStore.test.ts b/packages/shared/src/stores/createProfileWalletStore.test.ts index 39502b4a9..d883685a8 100644 --- a/packages/shared/src/stores/createProfileWalletStore.test.ts +++ b/packages/shared/src/stores/createProfileWalletStore.test.ts @@ -19,7 +19,9 @@ function balance(totalPoints: number): ProfileMudPointBalance { } function center(totalPoints: number): ProfileRechargeCenterResponse { - return { mudPointBalance: balance(totalPoints) } as ProfileRechargeCenterResponse; + return { + mudPointBalance: balance(totalPoints), + } as ProfileRechargeCenterResponse; } function deferred() { @@ -91,7 +93,9 @@ describe('createProfileWalletStore', () => { store.getState().setWalletOwner('user-a'); const refresh = store.getState().onWalletBalanceMayHaveChanged(); - store.getState().applyWalletBalanceSnapshot('user-a', balance(80)); + const snapshot = store.getState().captureWalletBalanceSnapshot('user-a'); + expect(snapshot).not.toBeNull(); + store.getState().applyWalletBalanceSnapshot(snapshot!, balance(80)); pending.resolve(center(20)); await refresh; @@ -114,7 +118,8 @@ describe('createProfileWalletStore', () => { }); const store = createProfileWalletStore({ getRechargeCenter }); store.getState().setWalletOwner('user-a'); - store.getState().applyWalletBalanceSnapshot('user-a', balance(50)); + const snapshot = store.getState().captureWalletBalanceSnapshot('user-a'); + store.getState().applyWalletBalanceSnapshot(snapshot!, balance(50)); const oldRefresh = store.getState().onWalletBalanceMayHaveChanged(); store.getState().setWalletOwner('user-b'); @@ -143,7 +148,8 @@ describe('createProfileWalletStore', () => { getRechargeCenter: vi.fn().mockRejectedValue(new Error('network down')), }); store.getState().setWalletOwner('user-a'); - store.getState().applyWalletBalanceSnapshot('user-a', balance(55)); + const snapshot = store.getState().captureWalletBalanceSnapshot('user-a'); + store.getState().applyWalletBalanceSnapshot(snapshot!, balance(55)); await store.getState().onWalletBalanceMayHaveChanged(); @@ -171,14 +177,15 @@ describe('createProfileWalletStore', () => { }); }); - test('ignores a snapshot captured for a different owner', () => { + test('does not capture a snapshot for a different owner', () => { const store = createProfileWalletStore({ getRechargeCenter: vi.fn(), }); store.getState().setWalletOwner('user-b'); - store.getState().applyWalletBalanceSnapshot('user-a', balance(100)); + const snapshot = store.getState().captureWalletBalanceSnapshot('user-a'); + expect(snapshot).toBeNull(); expect(store.getState().mudPointBalance).toBeNull(); expect(store.getState().mudPointBalanceStatus).toBe('idle'); }); @@ -189,13 +196,60 @@ describe('createProfileWalletStore', () => { }); store.getState().setWalletOwner('user-a'); - store + const snapshot = store .getState() - .applyWalletBalanceSnapshot(' user-a ', balance(100)); + .captureWalletBalanceSnapshot(' user-a '); + store.getState().applyWalletBalanceSnapshot(snapshot!, balance(100)); expect(store.getState()).toMatchObject({ mudPointBalance: balance(100), mudPointBalanceStatus: 'ready', }); }); + + test('does not let an older ordinary snapshot swallow a terminal refresh', async () => { + const terminalRefresh = deferred(); + const requestSignals: Array = []; + const store = createProfileWalletStore({ + getRechargeCenter: vi.fn((signal?: AbortSignal) => { + requestSignals.push(signal); + return terminalRefresh.promise; + }), + }); + store.getState().setWalletOwner('user-a'); + const ordinarySnapshot = store + .getState() + .captureWalletBalanceSnapshot('user-a'); + + const refresh = store.getState().onWalletBalanceMayHaveChanged(); + expect( + store + .getState() + .applyWalletBalanceSnapshot(ordinarySnapshot!, balance(80)), + ).toBe(false); + expect(requestSignals[0]?.aborted).toBe(false); + + terminalRefresh.resolve(center(100)); + await refresh; + + expect(store.getState().mudPointBalance?.totalPoints).toBe(100); + }); + + 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'); + const oldLifecycleSnapshot = store + .getState() + .captureWalletBalanceSnapshot('user-a'); + + store.getState().setWalletOwner('user-b'); + store.getState().setWalletOwner('user-a'); + + expect( + store + .getState() + .applyWalletBalanceSnapshot(oldLifecycleSnapshot!, balance(100)), + ).toBe(false); + expect(store.getState().mudPointBalance).toBeNull(); + }); }); diff --git a/packages/shared/src/stores/createProfileWalletStore.ts b/packages/shared/src/stores/createProfileWalletStore.ts index 527c5ff08..9b1f8a787 100644 --- a/packages/shared/src/stores/createProfileWalletStore.ts +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -8,19 +8,30 @@ import type { export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error'; export type ProfileWalletApi = { - getRechargeCenter(signal?: AbortSignal): Promise; + getRechargeCenter( + signal?: AbortSignal, + ): Promise; }; +export type ProfileWalletBalanceSnapshot = Readonly<{ + ownerUserId: string; + ownerVersion: number; + invalidationVersion: number; +}>; + export type ProfileWalletStore = { ownerUserId: string | null; mudPointBalance: ProfileMudPointBalance | null; mudPointBalanceStatus: MudPointBalanceStatus; mudPointBalanceError: string; setWalletOwner: (userId: string | null) => void; - applyWalletBalanceSnapshot: ( + captureWalletBalanceSnapshot: ( ownerUserId: string, + ) => ProfileWalletBalanceSnapshot | null; + applyWalletBalanceSnapshot: ( + snapshot: ProfileWalletBalanceSnapshot, balance: ProfileMudPointBalance, - ) => void; + ) => boolean; onWalletBalanceMayHaveChanged: () => Promise; resetWalletBalance: () => void; }; @@ -36,6 +47,7 @@ export function createProfileWalletStore( ): UseBoundStore> { let refreshVersion = 0; let settledRefreshVersion = 0; + let ownerVersion = 0; let requestGeneration = 0; let activeRefresh: Promise | null = null; let activeAbortController: AbortController | null = null; @@ -56,6 +68,7 @@ export function createProfileWalletStore( return; } + ownerVersion += 1; invalidateActiveRefresh(); settledRefreshVersion = refreshVersion; set({ @@ -63,22 +76,39 @@ export function createProfileWalletStore( ...EMPTY_WALLET_STATE, }); }, - applyWalletBalanceSnapshot: (ownerUserId, balance) => { + captureWalletBalanceSnapshot: (ownerUserId) => { const normalizedOwnerUserId = ownerUserId.trim(); if ( !normalizedOwnerUserId || get().ownerUserId !== normalizedOwnerUserId ) { - return; + return null; + } + + return { + ownerUserId: normalizedOwnerUserId, + ownerVersion, + invalidationVersion: refreshVersion, + }; + }, + applyWalletBalanceSnapshot: (snapshot, balance) => { + if ( + get().ownerUserId !== snapshot.ownerUserId || + ownerVersion !== snapshot.ownerVersion || + snapshot.invalidationVersion < refreshVersion || + snapshot.invalidationVersion < settledRefreshVersion + ) { + return false; } invalidateActiveRefresh(); - settledRefreshVersion = refreshVersion; + settledRefreshVersion = snapshot.invalidationVersion; set({ mudPointBalance: balance, mudPointBalanceStatus: 'ready', mudPointBalanceError: '', }); + return true; }, onWalletBalanceMayHaveChanged: () => { refreshVersion += 1; @@ -149,6 +179,7 @@ export function createProfileWalletStore( return refresh; }, resetWalletBalance: () => { + ownerVersion += 1; invalidateActiveRefresh(); settledRefreshVersion = refreshVersion; set({ ownerUserId: null, ...EMPTY_WALLET_STATE }); diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index b97b17d0c..5405b31d8 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -596,15 +596,20 @@ describe('ImageCanvasEditorView', () => { it('shows the live mud point balance in the canvas topbar when logged in', async () => { usePlatformWalletStore.getState().setWalletOwner('user-1'); - usePlatformWalletStore.getState().applyWalletBalanceSnapshot('user-1', { - totalPoints: 1234, - permanentPoints: 1000, - limitedPoints: 214, - limitedExpiresAt: '2026-07-31T16:00:00Z', - dailyFreePoints: 20, - dailyFreeResetPoints: 20, - dailyFreeResetsAt: '2026-07-12T16:00:00Z', - }); + const walletSnapshot = usePlatformWalletStore + .getState() + .captureWalletBalanceSnapshot('user-1'); + usePlatformWalletStore + .getState() + .applyWalletBalanceSnapshot(walletSnapshot!, { + totalPoints: 1234, + permanentPoints: 1000, + limitedPoints: 214, + limitedExpiresAt: '2026-07-31T16:00:00Z', + dailyFreePoints: 20, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-07-12T16:00:00Z', + }); render( { it('opens the shared wallet breakdown and ledger from the canvas topbar', async () => { const user = userEvent.setup(); usePlatformWalletStore.getState().setWalletOwner('user-1'); - usePlatformWalletStore.getState().applyWalletBalanceSnapshot('user-1', { - totalPoints: 1234, - permanentPoints: 1000, - limitedPoints: 0, - limitedExpiresAt: null, - dailyFreePoints: 234, - dailyFreeResetPoints: 20, - dailyFreeResetsAt: '2026-07-12T16:00:00Z', - }); + const walletSnapshot = usePlatformWalletStore + .getState() + .captureWalletBalanceSnapshot('user-1'); + usePlatformWalletStore + .getState() + .applyWalletBalanceSnapshot(walletSnapshot!, { + totalPoints: 1234, + permanentPoints: 1000, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 234, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-07-12T16:00:00Z', + }); render( state.applyWalletBalanceSnapshot, ); + const captureWalletBalanceSnapshot = usePlatformWalletStore( + (state) => state.captureWalletBalanceSnapshot, + ); const onWalletBalanceMayHaveChanged = usePlatformWalletStore( (state) => state.onWalletBalanceMayHaveChanged, ); @@ -402,8 +401,9 @@ export function usePlatformProfileCenterController({ setRewardCodeSuccess(null); setIsSubmittingReferralRedeem(false); }, [currentUserId]); - const rechargeCenterReadAbortControllerRef = - useRef(null); + const rechargeCenterReadAbortControllerRef = useRef( + null, + ); useEffect(() => { return () => { @@ -480,7 +480,11 @@ export function usePlatformProfileCenterController({ }, [loadWalletLedger]); const applyRechargeCenter = useCallback( - (center: ProfileRechargeCenterResponse, refreshWallet = false) => { + ( + center: ProfileRechargeCenterResponse, + refreshWallet = false, + walletSnapshot = captureWalletBalanceSnapshot(currentUserId), + ) => { if ( !currentUserId || usePlatformWalletStore.getState().ownerUserId !== currentUserId @@ -493,8 +497,8 @@ export function usePlatformProfileCenterController({ setIsLoadingRechargeCenter(false); setRechargeError(null); setRechargeCenter(center); - if (center.mudPointBalance) { - applyWalletBalanceSnapshot(currentUserId, center.mudPointBalance); + if (center.mudPointBalance && walletSnapshot) { + applyWalletBalanceSnapshot(walletSnapshot, center.mudPointBalance); } if (refreshWallet || !center.mudPointBalance) { void onWalletBalanceMayHaveChanged(); @@ -503,12 +507,14 @@ export function usePlatformProfileCenterController({ }, [ applyWalletBalanceSnapshot, + captureWalletBalanceSnapshot, currentUserId, onWalletBalanceMayHaveChanged, ], ); const loadRechargeCenter = useCallback(() => { + const walletSnapshot = captureWalletBalanceSnapshot(currentUserId); const revision = ++rechargeCenterReadRevisionRef.current; rechargeCenterReadAbortControllerRef.current?.abort(); const abortController = new AbortController(); @@ -521,7 +527,7 @@ export function usePlatformProfileCenterController({ !abortController.signal.aborted && revision === rechargeCenterReadRevisionRef.current ) { - applyRechargeCenter(center); + applyRechargeCenter(center, false, walletSnapshot); } }) .catch((error: unknown) => { @@ -544,7 +550,7 @@ export function usePlatformProfileCenterController({ setIsLoadingRechargeCenter(false); } }); - }, [applyRechargeCenter]); + }, [applyRechargeCenter, captureWalletBalanceSnapshot, currentUserId]); const refreshRechargeState = useCallback(() => { loadRechargeCenter();