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 1c38a4c36..0cbe9fd66 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 @@ -17,9 +17,11 @@ import { useWalletStore } from '../../stores/useWalletStore'; export function useAccountWallet(currentUserId: string) { const { + ownerUserId, mudPointBalance, mudPointBalanceStatus, mudPointBalanceError, + setWalletOwner, applyWalletBalanceSnapshot, onWalletBalanceMayHaveChanged, resetWalletBalance, @@ -41,32 +43,70 @@ export function useAccountWallet(currentUserId: string) { const [nativeRechargePayment, setNativeRechargePayment] = useState(null); const rechargeLifecycleRef = useRef(0); + const walletOwnerMatchesCurrentUser = + Boolean(currentUserId) && ownerUserId === currentUserId; + const visibleMudPointBalance = walletOwnerMatchesCurrentUser + ? mudPointBalance + : null; + const visibleMudPointBalanceStatus = walletOwnerMatchesCurrentUser + ? mudPointBalanceStatus + : 'idle'; + const visibleMudPointBalanceError = walletOwnerMatchesCurrentUser + ? mudPointBalanceError + : ''; useEffect(() => { - resetWalletBalance(); - void onWalletBalanceMayHaveChanged(); + setWalletOwner(currentUserId || null); + if (currentUserId) { + void onWalletBalanceMayHaveChanged(); + } const refreshWalletBalance = () => { - void onWalletBalanceMayHaveChanged(); + if (currentUserId) { + void onWalletBalanceMayHaveChanged(); + } }; window.addEventListener('focus', refreshWalletBalance); return () => { window.removeEventListener('focus', refreshWalletBalance); }; - }, [currentUserId, onWalletBalanceMayHaveChanged, resetWalletBalance]); + }, [currentUserId, onWalletBalanceMayHaveChanged, setWalletOwner]); + + useEffect(() => { + rechargeLifecycleRef.current += 1; + setWalletLedgerOpen(false); + setWalletLedger(null); + setWalletLedgerLoading(false); + setWalletLedgerError(null); + setRechargeOpen(false); + setRechargeContent(null); + setRechargeLoading(false); + setRechargeError(null); + setSubmittingRechargeProductId(null); + setNativeRechargePayment(null); + }, [currentUserId]); async function loadWalletLedger() { + const rechargeLifecycle = rechargeLifecycleRef.current; setWalletLedgerLoading(true); setWalletLedgerError(null); try { - setWalletLedger(await getClientProfileWalletLedger()); + const ledger = await getClientProfileWalletLedger(); + if (rechargeLifecycleRef.current === rechargeLifecycle) { + setWalletLedger(ledger); + } } catch (error) { + if (rechargeLifecycleRef.current !== rechargeLifecycle) { + return; + } setWalletLedger(null); setWalletLedgerError( error instanceof Error ? error.message : '读取泥点账单失败', ); } finally { - setWalletLedgerLoading(false); + if (rechargeLifecycleRef.current === rechargeLifecycle) { + setWalletLedgerLoading(false); + } } } @@ -75,7 +115,10 @@ export function useAccountWallet(currentUserId: string) { void loadWalletLedger(); } - function applyRechargeContent(center: ProfileRechargeCenterResponse) { + function applyRechargeContent( + snapshotOwnerUserId: string, + center: ProfileRechargeCenterResponse, + ) { const { walletBalance, mudPointBalance: nextMudPointBalance, @@ -83,12 +126,15 @@ export function useAccountWallet(currentUserId: string) { } = center; void walletBalance; if (nextMudPointBalance) { - applyWalletBalanceSnapshot(nextMudPointBalance); + applyWalletBalanceSnapshot(snapshotOwnerUserId, nextMudPointBalance); + } else { + void onWalletBalanceMayHaveChanged(); } setRechargeContent(content); } async function loadRechargeCenter() { + const snapshotOwnerUserId = currentUserId; const rechargeLifecycle = rechargeLifecycleRef.current; setRechargeLoading(true); setRechargeError(null); @@ -97,7 +143,7 @@ export function useAccountWallet(currentUserId: string) { if (rechargeLifecycleRef.current !== rechargeLifecycle) { return; } - applyRechargeContent(center); + applyRechargeContent(snapshotOwnerUserId, center); } catch (error) { if (rechargeLifecycleRef.current === rechargeLifecycle) { setRechargeError( @@ -130,6 +176,7 @@ export function useAccountWallet(currentUserId: string) { return; } const rechargeLifecycle = rechargeLifecycleRef.current; + const snapshotOwnerUserId = currentUserId; setSubmittingRechargeProductId(product.productId); setRechargeError(null); try { @@ -139,7 +186,7 @@ export function useAccountWallet(currentUserId: string) { if (rechargeLifecycleRef.current !== rechargeLifecycle) { return; } - applyRechargeContent(response.center); + applyRechargeContent(snapshotOwnerUserId, response.center); const nativePayment = response.wechatNativePayment; const codeUrl = nativePayment?.codeUrl?.trim(); const expiresAt = nativePayment?.expiresAt?.trim(); @@ -170,6 +217,7 @@ export function useAccountWallet(currentUserId: string) { return; } const rechargeLifecycle = rechargeLifecycleRef.current; + const snapshotOwnerUserId = currentUserId; const orderId = nativeRechargePayment.orderId; setNativeRechargePayment((current) => current?.orderId === orderId @@ -181,7 +229,7 @@ export function useAccountWallet(currentUserId: string) { if (rechargeLifecycleRef.current !== rechargeLifecycle) { return; } - applyRechargeContent(response.center); + applyRechargeContent(snapshotOwnerUserId, response.center); if (response.order.status === 'paid') { setNativeRechargePayment(null); void onWalletBalanceMayHaveChanged(); @@ -214,9 +262,10 @@ export function useAccountWallet(currentUserId: string) { } return { - mudPointBalance, - mudPointBalanceStatus, - mudPointBalanceError, + ownerUserId, + mudPointBalance: visibleMudPointBalance, + mudPointBalanceStatus: visibleMudPointBalanceStatus, + mudPointBalanceError: visibleMudPointBalanceError, onWalletBalanceMayHaveChanged, resetWalletBalance, walletLedgerOpen, @@ -226,11 +275,11 @@ export function useAccountWallet(currentUserId: string) { walletLedgerError, rechargeOpen, rechargeModalCenter: - rechargeContent && mudPointBalance + rechargeContent && visibleMudPointBalance ? { ...rechargeContent, - walletBalance: mudPointBalance.totalPoints, - mudPointBalance, + walletBalance: visibleMudPointBalance.totalPoints, + mudPointBalance: visibleMudPointBalance, } : null, rechargeLoading, diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index c16b24437..abfabb420 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -137,10 +137,10 @@ export function getClientProfileDashboard() { ); } -export function getClientProfileRechargeCenter() { +export function getClientProfileRechargeCenter(signal?: AbortSignal) { return requestClientApi( '/api/profile/recharge-center', - { method: 'GET' }, + { method: 'GET', signal }, '读取泥点明细失败', ); } diff --git a/apps/ai-game-creator-shell/src/stores/useWalletStore.ts b/apps/ai-game-creator-shell/src/stores/useWalletStore.ts index eba4d1b48..9f31d6e04 100644 --- a/apps/ai-game-creator-shell/src/stores/useWalletStore.ts +++ b/apps/ai-game-creator-shell/src/stores/useWalletStore.ts @@ -1,100 +1,6 @@ -import { create } from 'zustand'; - -import type { ProfileMudPointBalance } from '../../../../packages/shared/src'; +import { createProfileWalletStore } from '../../../../packages/shared/src'; import { getClientProfileRechargeCenter } from '../services/clientApi'; -export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error'; - -type WalletStore = { - mudPointBalance: ProfileMudPointBalance | null; - mudPointBalanceStatus: MudPointBalanceStatus; - mudPointBalanceError: string; - applyWalletBalanceSnapshot: (balance: ProfileMudPointBalance) => void; - onWalletBalanceMayHaveChanged: () => Promise; - resetWalletBalance: () => void; -}; - -const initialWalletState: Pick< - WalletStore, - 'mudPointBalance' | 'mudPointBalanceStatus' | 'mudPointBalanceError' -> = { - mudPointBalance: null, - mudPointBalanceStatus: 'idle', - mudPointBalanceError: '', -}; - -let refreshVersion = 0; -let requestGeneration = 0; -let activeRefresh: Promise | null = null; - -export const useWalletStore = create((set) => ({ - ...initialWalletState, - applyWalletBalanceSnapshot: (balance) => { - requestGeneration += 1; - refreshVersion = 0; - activeRefresh = null; - set({ - mudPointBalance: balance, - mudPointBalanceStatus: 'ready', - mudPointBalanceError: '', - }); - }, - onWalletBalanceMayHaveChanged: () => { - refreshVersion += 1; - if (activeRefresh) { - return activeRefresh; - } - - const refreshRequestGeneration = requestGeneration; - const refresh = (async () => { - while (refreshRequestGeneration === requestGeneration) { - const requestedVersion = refreshVersion; - set({ mudPointBalanceStatus: 'loading', mudPointBalanceError: '' }); - try { - const center = await getClientProfileRechargeCenter(); - if (!center.mudPointBalance) { - throw new Error('充值中心响应缺少泥点余额'); - } - if (refreshRequestGeneration !== requestGeneration) { - return; - } - if (requestedVersion !== refreshVersion) { - continue; - } - set({ - mudPointBalance: center.mudPointBalance, - mudPointBalanceStatus: 'ready', - mudPointBalanceError: '', - }); - return; - } catch (error) { - if (refreshRequestGeneration !== requestGeneration) { - return; - } - if (requestedVersion !== refreshVersion) { - continue; - } - set({ - mudPointBalanceStatus: 'error', - mudPointBalanceError: - error instanceof Error ? error.message : '泥点明细读取失败', - }); - return; - } - } - })(); - activeRefresh = refresh; - void refresh.finally(() => { - if (activeRefresh === refresh) { - activeRefresh = null; - } - }); - return refresh; - }, - resetWalletBalance: () => { - requestGeneration += 1; - refreshVersion = 0; - activeRefresh = null; - set(initialWalletState); - }, -})); +export const useWalletStore = createProfileWalletStore({ + getRechargeCenter: getClientProfileRechargeCenter, +}); diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index 7a573f945..8d5c5b788 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -1,15 +1,21 @@ /** @vitest-environment jsdom */ import { act, renderHook } from '@testing-library/react'; +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; import { beforeEach, describe, expect, test, vi } from 'vitest'; const clientApi = vi.hoisted(() => ({ + confirmClientWechatProfileRechargeOrder: vi.fn(), + createClientProfileRechargeOrder: vi.fn(), getClientProfileRechargeCenter: vi.fn(), + getClientProfileWalletLedger: vi.fn(), })); vi.mock('../src/services/clientApi', () => clientApi); import { useWalletStore } from '../src/stores/useWalletStore'; +import { useAccountWallet } from '../src/features/app-shell/useAccountWallet'; function detailedBalance(totalPoints: number) { return { @@ -26,6 +32,7 @@ function detailedBalance(totalPoints: number) { describe('useWalletStore', () => { beforeEach(() => { useWalletStore.getState().resetWalletBalance(); + useWalletStore.getState().setWalletOwner('user-a'); vi.clearAllMocks(); }); @@ -50,7 +57,9 @@ describe('useWalletStore', () => { 'mudPointBalanceError', 'mudPointBalanceStatus', 'onWalletBalanceMayHaveChanged', + 'ownerUserId', 'resetWalletBalance', + 'setWalletOwner', ]); }); @@ -61,7 +70,9 @@ describe('useWalletStore', () => { await useWalletStore.getState().onWalletBalanceMayHaveChanged(); const mudPointBalance = detailedBalance(120); - useWalletStore.getState().applyWalletBalanceSnapshot(mudPointBalance); + useWalletStore + .getState() + .applyWalletBalanceSnapshot('user-a', mudPointBalance); expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready'); @@ -79,7 +90,9 @@ describe('useWalletStore', () => { const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged(); const mudPointBalance = detailedBalance(160); - useWalletStore.getState().applyWalletBalanceSnapshot(mudPointBalance); + useWalletStore + .getState() + .applyWalletBalanceSnapshot('user-a', mudPointBalance); rejectRequest?.(new Error('过期请求失败')); await refresh; @@ -179,4 +192,57 @@ describe('useWalletStore', () => { expect(useWalletStore.getState().mudPointBalance).toBeNull(); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('idle'); }); + + test('clears immediately when the adapter switches wallet owners', () => { + useWalletStore + .getState() + .applyWalletBalanceSnapshot('user-a', detailedBalance(88)); + + useWalletStore.getState().setWalletOwner('user-b'); + + expect(useWalletStore.getState()).toMatchObject({ + ownerUserId: 'user-b', + mudPointBalance: null, + mudPointBalanceStatus: 'idle', + }); + }); + + test('masks another owner snapshot before the owner-binding effect runs', () => { + useWalletStore + .getState() + .applyWalletBalanceSnapshot('user-a', detailedBalance(88)); + + function WalletProbe() { + const wallet = useAccountWallet('user-b'); + return createElement( + 'span', + null, + wallet.mudPointBalance?.totalPoints ?? 'empty', + ); + } + + expect(renderToString(createElement(WalletProbe))).toContain('empty'); + expect(useWalletStore.getState().ownerUserId).toBe('user-a'); + }); + + test('binds the account owner and refreshes again when the window regains focus', async () => { + clientApi.getClientProfileRechargeCenter.mockResolvedValue({ + walletBalance: 18, + mudPointBalance: detailedBalance(18), + }); + + renderHook(() => useAccountWallet('user-a')); + await act(async () => undefined); + expect(clientApi.getClientProfileRechargeCenter).toHaveBeenCalledTimes(1); + + await act(async () => { + window.dispatchEvent(new Event('focus')); + }); + + expect(clientApi.getClientProfileRechargeCenter).toHaveBeenCalledTimes(2); + expect(useWalletStore.getState()).toMatchObject({ + ownerUserId: 'user-a', + mudPointBalance: detailedBalance(18), + }); + }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b5962f16f..7edfb6a44 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5931,3 +5931,12 @@ - Agent 发现:新增公开 `agent-integration.json`、`skill/SKILL.md` 和 `skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。 - 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。 - 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。 + +## 2026-08-03 主站与 AI Game Creator 复用单一泥点钱包 Store + +- 背景:主站顶部优先读取 dashboard 总额,图片画板独立轮询 dashboard,充值 controller 和 AI Game Creator 又分别保存充值中心明细;不同请求返回时序不一致会让总额与分桶同时显示不同快照,快速生成或切换账号时旧响应还可能回滚余额。 +- 决策:在 `packages/shared` 提供依赖注入式 `createProfileWalletStore`,统一保存 `ownerUserId`、完整 `ProfileMudPointBalance`、读取状态与错误,并在每个实例的独立闭包内完成请求合并、尾随补读、generation 失效和 owner 校验。主站注入 `getPlatformProfileRechargeCenter`,AI Game Creator 注入 `getClientProfileRechargeCenter`;两端不共享 transport、认证或重试实现。主站顶部、图片画板顶部和“我的”统计只消费同一 Store 快照,总额固定取 `totalPoints`;dashboard 的 `walletBalance` 只保留后端兼容,不再作为钱包 UI 数据源。 +- 并发与账号边界:同一 owner generation 同时只执行一个余额读取;读取期间的新变化通知在当前请求结束后补读,直到覆盖最后一次通知。切换或退出账号立即清空快照、提升 generation、中止并脱离旧 generation 的 active 请求,新 owner 不得等待旧 transport 收束;不响应 abort 的旧 transport 可以在后台结束,但其结果必须忽略。消费端在 owner 绑定 effect 提交前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,不能让旧余额与新账号身份同屏。充值中心读取或 mutation 响应必须携带请求开始时捕获的 owner,owner 不符时忽略。刷新失败保留已有快照;响应缺少 `mudPointBalance` 时进入错误状态,不用总额反推分桶。 +- UI 与 mutation:充值 controller 继续保存商品、订单和支付状态,但充值中心响应必须同步写入共享快照,余额 mutation 应在应用响应后再通知一次合并刷新。充值弹窗的余额和分桶由当前 Store 快照覆盖。生成完成、失败退款、兑换码和邀请奖励等事件只发送余额可能变化通知;账号变化同时清理旧充值中心、账单与支付临时状态。`limitedPoints` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。 +- 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。 +- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 082347839..75be4392b 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -59,7 +59,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 ## 账户与充值 -1. 主站和图片画板统一使用公共泥点资产入口。收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及“每天重置为 20 泥点”,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 +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 为准,前端不得自行相减推算。 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/index.ts b/packages/shared/src/index.ts index a6f0f50f8..da4bf484a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -50,5 +50,6 @@ export type * from './contracts/visualNovel'; export * from './http'; export * from './llm/narrativeLanguage'; export * from './llm/parsers'; +export * from './stores/createProfileWalletStore'; // should not export component, instead, they should be ref by relative path diff --git a/packages/shared/src/stores/createProfileWalletStore.test.ts b/packages/shared/src/stores/createProfileWalletStore.test.ts new file mode 100644 index 000000000..92399e2cf --- /dev/null +++ b/packages/shared/src/stores/createProfileWalletStore.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test, vi } from 'vitest'; + +import type { + ProfileMudPointBalance, + ProfileRechargeCenterResponse, +} from '../contracts/runtime'; +import { createProfileWalletStore } from './createProfileWalletStore'; + +function balance(totalPoints: number): ProfileMudPointBalance { + return { + totalPoints, + permanentPoints: totalPoints, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', + }; +} + +function center(totalPoints: number): ProfileRechargeCenterResponse { + return { mudPointBalance: balance(totalPoints) } as ProfileRechargeCenterResponse; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe('createProfileWalletStore', () => { + test('reads the first complete wallet snapshot for its owner', async () => { + const getRechargeCenter = vi.fn().mockResolvedValue(center(42)); + const store = createProfileWalletStore({ getRechargeCenter }); + + store.getState().setWalletOwner('user-a'); + await store.getState().onWalletBalanceMayHaveChanged(); + + expect(getRechargeCenter).toHaveBeenCalledTimes(1); + expect(store.getState()).toMatchObject({ + ownerUserId: 'user-a', + mudPointBalance: balance(42), + mudPointBalanceStatus: 'ready', + mudPointBalanceError: '', + }); + }); + + test('merges concurrent notifications into one request', async () => { + const pending = deferred(); + const getRechargeCenter = vi.fn(() => pending.promise); + const store = createProfileWalletStore({ getRechargeCenter }); + store.getState().setWalletOwner('user-a'); + + const first = store.getState().onWalletBalanceMayHaveChanged(); + const second = store.getState().onWalletBalanceMayHaveChanged(); + expect(first).toBe(second); + expect(getRechargeCenter).toHaveBeenCalledTimes(1); + + pending.resolve(center(12)); + await first; + expect(getRechargeCenter).toHaveBeenCalledTimes(2); + }); + + test('reads again when balance changes during an active request', async () => { + const first = deferred(); + const getRechargeCenter = vi + .fn() + .mockImplementationOnce(() => first.promise) + .mockResolvedValueOnce(center(30)); + const store = createProfileWalletStore({ getRechargeCenter }); + store.getState().setWalletOwner('user-a'); + + const refresh = store.getState().onWalletBalanceMayHaveChanged(); + void store.getState().onWalletBalanceMayHaveChanged(); + first.resolve(center(99)); + await refresh; + + expect(getRechargeCenter).toHaveBeenCalledTimes(2); + expect(store.getState().mudPointBalance?.totalPoints).toBe(30); + }); + + test('does not let an older read overwrite an applied mutation snapshot', async () => { + const pending = deferred(); + const store = createProfileWalletStore({ + getRechargeCenter: vi.fn(() => pending.promise), + }); + store.getState().setWalletOwner('user-a'); + + const refresh = store.getState().onWalletBalanceMayHaveChanged(); + store.getState().applyWalletBalanceSnapshot('user-a', balance(80)); + pending.resolve(center(20)); + await refresh; + + expect(store.getState().mudPointBalance?.totalPoints).toBe(80); + }); + + test('refreshes the new owner without waiting for a stalled old-owner request', async () => { + const oldOwner = deferred(); + const newOwner = deferred(); + const requestSignals: Array = []; + const getRechargeCenter = vi + .fn() + .mockImplementationOnce((signal?: AbortSignal) => { + requestSignals.push(signal); + return oldOwner.promise; + }) + .mockImplementationOnce((signal?: AbortSignal) => { + requestSignals.push(signal); + return newOwner.promise; + }); + const store = createProfileWalletStore({ getRechargeCenter }); + store.getState().setWalletOwner('user-a'); + store.getState().applyWalletBalanceSnapshot('user-a', balance(50)); + + const oldRefresh = store.getState().onWalletBalanceMayHaveChanged(); + store.getState().setWalletOwner('user-b'); + expect(store.getState()).toMatchObject({ + ownerUserId: 'user-b', + mudPointBalance: null, + mudPointBalanceStatus: 'idle', + }); + const newRefresh = store.getState().onWalletBalanceMayHaveChanged(); + + expect(getRechargeCenter).toHaveBeenCalledTimes(2); + expect(newRefresh).not.toBe(oldRefresh); + expect(requestSignals[0]?.aborted).toBe(true); + expect(requestSignals[1]?.aborted).toBe(false); + newOwner.resolve(center(7)); + await newRefresh; + expect(store.getState().mudPointBalance?.totalPoints).toBe(7); + + oldOwner.resolve(center(60)); + await oldRefresh; + expect(store.getState().mudPointBalance?.totalPoints).toBe(7); + }); + + test('keeps an existing snapshot when refresh fails', async () => { + const store = createProfileWalletStore({ + getRechargeCenter: vi.fn().mockRejectedValue(new Error('network down')), + }); + store.getState().setWalletOwner('user-a'); + store.getState().applyWalletBalanceSnapshot('user-a', balance(55)); + + await store.getState().onWalletBalanceMayHaveChanged(); + + expect(store.getState()).toMatchObject({ + mudPointBalance: balance(55), + mudPointBalanceStatus: 'error', + mudPointBalanceError: 'network down', + }); + }); + + test('treats a recharge response without the balance breakdown as an error', async () => { + const store = createProfileWalletStore({ + getRechargeCenter: vi + .fn() + .mockResolvedValue({} as ProfileRechargeCenterResponse), + }); + store.getState().setWalletOwner('user-a'); + + await store.getState().onWalletBalanceMayHaveChanged(); + + expect(store.getState()).toMatchObject({ + mudPointBalance: null, + mudPointBalanceStatus: 'error', + mudPointBalanceError: '充值中心响应缺少泥点余额', + }); + }); + + test('ignores a snapshot captured for a different owner', () => { + const store = createProfileWalletStore({ + getRechargeCenter: vi.fn(), + }); + store.getState().setWalletOwner('user-b'); + + store.getState().applyWalletBalanceSnapshot('user-a', balance(100)); + + expect(store.getState().mudPointBalance).toBeNull(); + expect(store.getState().mudPointBalanceStatus).toBe('idle'); + }); +}); diff --git a/packages/shared/src/stores/createProfileWalletStore.ts b/packages/shared/src/stores/createProfileWalletStore.ts new file mode 100644 index 000000000..db1c703b9 --- /dev/null +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -0,0 +1,152 @@ +import { create, type StoreApi, type UseBoundStore } from 'zustand'; + +import type { + ProfileMudPointBalance, + ProfileRechargeCenterResponse, +} from '../contracts/runtime'; + +export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error'; + +export type ProfileWalletApi = { + getRechargeCenter(signal?: AbortSignal): Promise; +}; + +export type ProfileWalletStore = { + ownerUserId: string | null; + mudPointBalance: ProfileMudPointBalance | null; + mudPointBalanceStatus: MudPointBalanceStatus; + mudPointBalanceError: string; + setWalletOwner: (userId: string | null) => void; + applyWalletBalanceSnapshot: ( + ownerUserId: string, + balance: ProfileMudPointBalance, + ) => void; + onWalletBalanceMayHaveChanged: () => Promise; + resetWalletBalance: () => void; +}; + +const EMPTY_WALLET_STATE = { + mudPointBalance: null, + mudPointBalanceStatus: 'idle', + mudPointBalanceError: '', +} as const; + +export function createProfileWalletStore( + api: ProfileWalletApi, +): UseBoundStore> { + let refreshVersion = 0; + let settledRefreshVersion = 0; + let requestGeneration = 0; + let activeRefresh: Promise | null = null; + let activeAbortController: AbortController | null = null; + + const invalidateActiveRefresh = () => { + requestGeneration += 1; + activeAbortController?.abort(); + activeAbortController = null; + activeRefresh = null; + }; + + return create((set, get) => ({ + ownerUserId: null, + ...EMPTY_WALLET_STATE, + setWalletOwner: (userId) => { + const normalizedUserId = userId?.trim() || null; + if (get().ownerUserId === normalizedUserId) { + return; + } + + invalidateActiveRefresh(); + settledRefreshVersion = refreshVersion; + set({ + ownerUserId: normalizedUserId, + ...EMPTY_WALLET_STATE, + }); + }, + applyWalletBalanceSnapshot: (ownerUserId, balance) => { + if (!ownerUserId || get().ownerUserId !== ownerUserId) { + return; + } + + invalidateActiveRefresh(); + settledRefreshVersion = refreshVersion; + set({ + mudPointBalance: balance, + mudPointBalanceStatus: 'ready', + mudPointBalanceError: '', + }); + }, + onWalletBalanceMayHaveChanged: () => { + refreshVersion += 1; + if (activeRefresh) { + return activeRefresh; + } + + const abortController = new AbortController(); + const refresh = (async () => { + while (settledRefreshVersion < refreshVersion) { + const ownerUserId = get().ownerUserId; + const requestedVersion = refreshVersion; + const generation = requestGeneration; + if (!ownerUserId) { + settledRefreshVersion = requestedVersion; + return; + } + + set({ mudPointBalanceStatus: 'loading', mudPointBalanceError: '' }); + try { + const center = await api.getRechargeCenter(abortController.signal); + if (generation !== requestGeneration) { + return; + } + if (requestedVersion !== refreshVersion) { + settledRefreshVersion = requestedVersion; + continue; + } + if (!center.mudPointBalance) { + throw new Error('充值中心响应缺少泥点余额'); + } + + settledRefreshVersion = requestedVersion; + set({ + mudPointBalance: center.mudPointBalance, + mudPointBalanceStatus: 'ready', + mudPointBalanceError: '', + }); + } catch (error) { + if (generation !== requestGeneration) { + return; + } + if (requestedVersion !== refreshVersion) { + settledRefreshVersion = requestedVersion; + continue; + } + + settledRefreshVersion = requestedVersion; + set({ + mudPointBalanceStatus: 'error', + mudPointBalanceError: + error instanceof Error ? error.message : '泥点明细读取失败', + }); + } + } + })(); + activeRefresh = refresh; + activeAbortController = abortController; + void refresh.finally(() => { + if (activeRefresh === refresh) { + activeRefresh = null; + } + if (activeAbortController === abortController) { + activeAbortController = null; + } + }); + return refresh; + }, + resetWalletBalance: () => { + 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 0182d2329..1db8e2570 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -13,6 +13,7 @@ import JSZip from 'jszip'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient'; +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation'; import { ApiClientError, @@ -295,6 +296,7 @@ describe('ImageCanvasEditorView', () => { }); beforeEach(() => { + usePlatformWalletStore.getState().resetWalletBalance(); loadFrontendRuntimeConfigMock.mockImplementation(() => immediateAsync({ imageEditorAgentSidebarEnabled: false, @@ -594,6 +596,16 @@ 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', + }); render( { ); expect(await screen.findByLabelText('泥点 1,234')).toBeTruthy(); - expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({ - authImpact: 'local', - skipRefresh: true, - notifyAuthStateChange: false, - clearAuthOnUnauthorized: false, - }); + expect(getPlatformProfileDashboardMock).not.toHaveBeenCalled(); }); it('opens the account modal from the canvas topbar avatar entry', async () => { @@ -661,6 +668,16 @@ describe('ImageCanvasEditorView', () => { 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', + }); render( (null); - const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false); + const walletOwnerUserId = usePlatformWalletStore( + (state) => state.ownerUserId, + ); + const storedMudPointBalance = usePlatformWalletStore( + (state) => state.mudPointBalance, + ); + const storedMudPointBalanceStatus = usePlatformWalletStore( + (state) => state.mudPointBalanceStatus, + ); + const storedMudPointBalanceError = usePlatformWalletStore( + (state) => state.mudPointBalanceError, + ); + const onWalletBalanceMayHaveChanged = usePlatformWalletStore( + (state) => state.onWalletBalanceMayHaveChanged, + ); + const currentWalletOwnerUserId = + authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null; + const walletOwnerMatchesCurrentUser = + Boolean(currentWalletOwnerUserId) && + walletOwnerUserId === currentWalletOwnerUserId; + const mudPointBalance = walletOwnerMatchesCurrentUser + ? storedMudPointBalance + : null; + const mudPointBalanceStatus = walletOwnerMatchesCurrentUser + ? storedMudPointBalanceStatus + : 'idle'; + const mudPointBalanceError = walletOwnerMatchesCurrentUser + ? storedMudPointBalanceError + : ''; + const walletBalance = mudPointBalance?.totalPoints ?? null; const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const assetListRef = useRef(null); @@ -489,21 +517,6 @@ export function ImageCanvasEditorView({ }, [], ); - const refreshEditorWalletBalance = useCallback(() => { - if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) { - return; - } - void getPlatformProfileDashboard({ - authImpact: 'local', - skipRefresh: true, - notifyAuthStateChange: false, - clearAuthOnUnauthorized: false, - }) - .then((dashboard) => { - setWalletBalance(dashboard.walletBalance); - }) - .catch(() => undefined); - }, []); const { buyRechargeProduct, closeNativeWechatPayment, @@ -539,14 +552,12 @@ export function ImageCanvasEditorView({ activeTab: 'editor-canvas', isAuthenticated: Boolean(authUi?.user), showRechargeEntry, - onRechargeSuccess: refreshEditorWalletBalance, requestLogin: () => authUiRef.current?.openLoginModal(), currentUser: authUi?.user ?? null, }); const refreshEditorWalletState = useCallback(() => { - refreshEditorWalletBalance(); - loadRechargeCenter(); - }, [loadRechargeCenter, refreshEditorWalletBalance]); + void onWalletBalanceMayHaveChanged(); + }, [onWalletBalanceMayHaveChanged]); const isAccountPaymentModalOpen = isRewardCodeOpen || isRechargeOpen || @@ -568,66 +579,6 @@ export function ImageCanvasEditorView({ window.location.reload(); }); }, [authUi]); - useEffect(() => { - if (!authUi?.canAccessProtectedData || !authUi.user?.id) { - setWalletBalance(null); - setIsWalletBalanceLoading(false); - return; - } - - let isMounted = true; - let requestId = 0; - - const refreshWalletBalance = () => { - const currentRequestId = requestId + 1; - requestId = currentRequestId; - setIsWalletBalanceLoading(true); - void getPlatformProfileDashboard({ - authImpact: 'local', - skipRefresh: true, - notifyAuthStateChange: false, - clearAuthOnUnauthorized: false, - }) - .then((dashboard) => { - if (!isMounted || currentRequestId !== requestId) { - return; - } - setWalletBalance(dashboard.walletBalance); - }) - .catch(() => { - if (!isMounted || currentRequestId !== requestId) { - return; - } - setWalletBalance(null); - }) - .finally(() => { - if (!isMounted || currentRequestId !== requestId) { - return; - } - setIsWalletBalanceLoading(false); - }); - }; - - refreshWalletBalance(); - - const handleWindowFocus = () => { - refreshWalletBalance(); - }; - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') { - refreshWalletBalance(); - } - }; - - window.addEventListener('focus', handleWindowFocus); - document.addEventListener('visibilitychange', handleVisibilityChange); - - return () => { - isMounted = false; - window.removeEventListener('focus', handleWindowFocus); - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [authUi?.canAccessProtectedData, authUi?.user?.id]); const { projectTitle, setProjectTitle, @@ -2317,10 +2268,10 @@ export function ImageCanvasEditorView({ projectRenameError, layers, walletBalance, - walletBreakdown: rechargeCenter?.mudPointBalance ?? null, - isWalletBalanceLoading, + walletBreakdown: mudPointBalance, + isWalletBalanceLoading: mudPointBalanceStatus === 'loading', isWalletDetailsLoading: isLoadingRechargeCenter, - walletDetailsError: rechargeError, + walletDetailsError: rechargeError || mudPointBalanceError || null, currentUser: authUi?.user, assetExportStatus, isExportingAssets, diff --git a/src/components/platform-entry/PlatformActiveProfileView.test.tsx b/src/components/platform-entry/PlatformActiveProfileView.test.tsx index 8b3e46468..bbcb0b93e 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.test.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.test.tsx @@ -48,6 +48,8 @@ describe('PlatformActiveProfileView', () => { {...callbacks} dashboard={null} isLoadingDashboard={false} + isLoadingWalletBalance={false} + mudPointBalance={null} user={null} />, ); @@ -68,6 +70,16 @@ describe('PlatformActiveProfileView', () => { updatedAt: '2026-07-18T00:00:00.000Z', }} isLoadingDashboard={false} + isLoadingWalletBalance={false} + mudPointBalance={{ + totalPoints: 108, + permanentPoints: 88, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 20, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', + }} user={authenticatedUser} />, ); @@ -95,6 +107,8 @@ describe('PlatformActiveProfileView', () => { {...callbacks} dashboard={null} isLoadingDashboard={false} + isLoadingWalletBalance={false} + mudPointBalance={null} user={authenticatedUser} />, ); @@ -127,6 +141,8 @@ describe('PlatformActiveProfileView', () => { {...callbacks} dashboard={null} isLoadingDashboard={false} + isLoadingWalletBalance={false} + mudPointBalance={null} user={authenticatedUser} />, ); @@ -189,6 +205,8 @@ describe('PlatformActiveProfileView', () => { {...callbacks} dashboard={null} isLoadingDashboard={false} + isLoadingWalletBalance={false} + mudPointBalance={null} user={authenticatedUser} />, ); diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx index 1c99cada2..034ce759d 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.tsx @@ -11,6 +11,12 @@ import { } from 'lucide-react'; import { useCallback, useRef, useState } from 'react'; +import type { AuthUser } from '@/packages/shared/src'; +import type { + ProfileDashboardSummary, + ProfileMudPointBalance, +} from '@/packages/shared/src'; + import profileClockImage from '../../../media/profile/_Image (1).png'; import profileGamepadImage from '../../../media/profile/_Image (2).png'; import profileStillLifeImage from '../../../media/profile/_Image (3).png'; @@ -20,8 +26,6 @@ import profileCommunityImage from '../../../media/profile/_Image (7).png'; import profileFeedbackImage from '../../../media/profile/_Image (8).png'; import profileMascotImage from '../../../media/profile/_Image (9).png'; import profilePointImage from '../../../media/profile/_Image.png'; -import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; -import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime'; import { updateAuthProfile } from '../../services/authService'; import { canUseNativeHostCapability, @@ -58,6 +62,8 @@ import { type PlatformActiveProfileViewProps = { dashboard: ProfileDashboardSummary | null; isLoadingDashboard: boolean; + isLoadingWalletBalance: boolean; + mudPointBalance: ProfileMudPointBalance | null; onLogin: () => void; onOpenApiKeys: () => void; onOpenCommunity: () => void; @@ -247,6 +253,8 @@ function formatTotalPlayTime(value: number) { export function PlatformActiveProfileView({ dashboard, isLoadingDashboard, + isLoadingWalletBalance, + mudPointBalance, onLogin, onOpenApiKeys, onOpenCommunity, @@ -540,9 +548,11 @@ export function PlatformActiveProfileView({ cardKey="wallet" label="泥点余额" value={ - dashboard - ? formatDashboardCount(dashboard.walletBalance) - : '暂不可用' + mudPointBalance + ? formatDashboardCount(mudPointBalance.totalPoints) + : isLoadingWalletBalance + ? '读取中' + : '暂不可用' } icon={Coins} imageSrc={profilePointImage} diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index bf527b766..399141c47 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -1,15 +1,17 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; import { PlatformEntryFlowShellImpl } from './PlatformEntryActiveFlowShell'; import type { SelectionStage } from './platformEntryActiveTypes'; +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; const authUiMock = vi.hoisted(() => ({ value: { - user: null, + user: null as AuthUser | null, canAccessProtectedData: false, openLoginModal: vi.fn(), openAccountModal: vi.fn(), @@ -21,6 +23,11 @@ const responsiveMock = vi.hoisted(() => ({ isDesktopLayout: true, })); +const profileClientMock = vi.hoisted(() => ({ + getPlatformProfileDashboard: vi.fn(), + getPlatformProfileRechargeCenter: vi.fn(), +})); + vi.mock('../auth/AuthUiContext', () => ({ useAuthUi: () => authUiMock.value, })); @@ -83,9 +90,9 @@ vi.mock('../image-editor/ImageCanvasEditorView', () => ({ ImageCanvasEditorView: () =>
, })); -vi.mock('../../services/platform-entry/platformProfileClient', () => ({ - getPlatformProfileDashboard: vi.fn(), -})); +vi.mock('../../services/platform-entry/platformProfileClient', () => + profileClientMock, +); vi.mock('./usePlatformProfileCenterController', () => ({ usePlatformProfileCenterController: () => ({ @@ -130,10 +137,179 @@ function StatefulPlatformEntryFlowShell({ describe('PlatformEntryActiveFlowShell', () => { beforeEach(() => { window.history.replaceState(null, '', '/creation'); + usePlatformWalletStore.getState().resetWalletBalance(); + profileClientMock.getPlatformProfileDashboard.mockReset(); + profileClientMock.getPlatformProfileRechargeCenter.mockReset(); + authUiMock.value.user = null; + authUiMock.value.canAccessProtectedData = false; authUiMock.value.openLoginModal.mockReset(); responsiveMock.isDesktopLayout = true; }); + it('uses one recharge-center snapshot for the topbar and profile wallet when dashboard differs', async () => { + authUiMock.value.user = { + id: 'user-1', + publicUserCode: '100001', + displayName: '测试用户', + avatarUrl: null, + phoneNumberMasked: null, + loginMethod: 'password', + bindingStatus: 'active', + wechatBound: false, + }; + authUiMock.value.canAccessProtectedData = true; + profileClientMock.getPlatformProfileDashboard.mockResolvedValue({ + walletBalance: 999, + totalPlayTimeMs: 0, + playedWorldCount: 0, + updatedAt: null, + }); + profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({ + walletBalance: 888, + mudPointBalance: { + totalPoints: 207, + permanentPoints: 180, + limitedPoints: 7, + limitedExpiresAt: '2026-08-31T16:00:00Z', + dailyFreePoints: 20, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', + }, + }); + + const { rerender } = render( + , + ); + + expect(await screen.findByLabelText('泥点 207')).toBeTruthy(); + + rerender( + , + ); + + expect( + await screen.findByRole('button', { name: '泥点余额 207' }), + ).toBeTruthy(); + expect(screen.queryByText('999')).toBeNull(); + }); + + it('clears the previous wallet synchronously when the authenticated account changes', async () => { + authUiMock.value.user = { + id: 'user-1', + publicUserCode: '100001', + displayName: '用户一', + avatarUrl: null, + phoneNumberMasked: null, + loginMethod: 'password', + bindingStatus: 'active', + wechatBound: false, + }; + authUiMock.value.canAccessProtectedData = true; + profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null); + profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({ + mudPointBalance: { + totalPoints: 66, + permanentPoints: 66, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', + }, + }); + const { rerender } = render( + , + ); + await screen.findByLabelText('泥点 66'); + + let resolveNextOwner!: (value: unknown) => void; + profileClientMock.getPlatformProfileRechargeCenter.mockImplementation( + () => + new Promise((resolve) => { + resolveNextOwner = resolve; + }), + ); + authUiMock.value.user = { ...authUiMock.value.user, id: 'user-2' }; + rerender( + , + ); + + expect(screen.queryByLabelText('泥点 66')).toBeNull(); + await waitFor(() => { + expect(usePlatformWalletStore.getState()).toMatchObject({ + ownerUserId: 'user-2', + mudPointBalance: null, + }); + }); + resolveNextOwner({ + mudPointBalance: { + totalPoints: 67, + permanentPoints: 67, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', + }, + }); + await screen.findByLabelText('泥点 67'); + }); + + it('refreshes the shared wallet when the authenticated page regains focus', async () => { + authUiMock.value.user = { + id: 'user-1', + publicUserCode: '100001', + displayName: '测试用户', + avatarUrl: null, + phoneNumberMasked: null, + loginMethod: 'password', + bindingStatus: 'active', + wechatBound: false, + }; + authUiMock.value.canAccessProtectedData = true; + profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null); + profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({ + mudPointBalance: { + totalPoints: 10, + permanentPoints: 10, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', + }, + }); + render( + , + ); + await screen.findByLabelText('泥点 10'); + + await act(async () => { + window.dispatchEvent(new Event('focus')); + }); + + await waitFor(() => { + expect( + profileClientMock.getPlatformProfileRechargeCenter, + ).toHaveBeenCalledTimes(2); + }); + }); + it('keeps the active desktop rail and the shared account capsule', async () => { const setSelectionStage = vi.fn(); const { container, rerender } = render( diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 8cd069729..0e0c4b236 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -25,6 +25,10 @@ import { replaceAppHistoryPath, } from '../../routing/activeAppPageRoutes'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; +import { + usePlatformWalletLifecycle, + usePlatformWalletStore, +} from '../../stores/usePlatformWalletStore'; import { useAuthUi } from '../auth/AuthUiContext'; import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel'; import { PlatformActionButton } from '../common/PlatformActionButton'; @@ -224,6 +228,36 @@ export function PlatformEntryFlowShellImpl({ const [isMobileDesktopGuideOpen, setIsMobileDesktopGuideOpen] = useState(false); const isDesktopLayout = usePlatformDesktopLayout(); + const currentWalletOwnerUserId = + authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null; + usePlatformWalletLifecycle( + currentWalletOwnerUserId, + Boolean(authUi?.canAccessProtectedData), + ); + const walletOwnerUserId = usePlatformWalletStore( + (state) => state.ownerUserId, + ); + const storedMudPointBalance = usePlatformWalletStore( + (state) => state.mudPointBalance, + ); + const storedMudPointBalanceStatus = usePlatformWalletStore( + (state) => state.mudPointBalanceStatus, + ); + const storedMudPointBalanceError = usePlatformWalletStore( + (state) => state.mudPointBalanceError, + ); + const walletOwnerMatchesCurrentUser = + Boolean(currentWalletOwnerUserId) && + walletOwnerUserId === currentWalletOwnerUserId; + const mudPointBalance = walletOwnerMatchesCurrentUser + ? storedMudPointBalance + : null; + const mudPointBalanceStatus = walletOwnerMatchesCurrentUser + ? storedMudPointBalanceStatus + : 'idle'; + const mudPointBalanceError = walletOwnerMatchesCurrentUser + ? storedMudPointBalanceError + : ''; const refreshDashboard = useCallback(async () => { if (!authUi?.user || !authUi.canAccessProtectedData) { @@ -250,7 +284,6 @@ export function PlatformEntryFlowShellImpl({ activeTab: isProfileStage ? 'profile' : 'project', isAuthenticated: Boolean(authUi?.user), showRechargeEntry: true, - onRechargeSuccess: refreshDashboard, requestLogin: () => authUi?.openLoginModal(), currentUser: authUi?.user, }); @@ -337,11 +370,7 @@ export function PlatformEntryFlowShellImpl({ } const isAuthenticated = Boolean(authUi?.user); - const balance = - dashboard?.walletBalance ?? - profileCenter.rechargeCenter?.mudPointBalance?.totalPoints ?? - profileCenter.rechargeCenter?.walletBalance ?? - null; + const balance = mudPointBalance?.totalPoints ?? null; const isCreationStage = !isProfileStage && (selectionStage === 'platform' || selectionStage === 'creation-home'); @@ -455,14 +484,9 @@ export function PlatformEntryFlowShellImpl({ authUi?.openLoginModal()} onOpenApiKeys={() => setIsApiKeysOpen(true)} diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 94c8d2f71..605e6c30b 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -36,6 +36,7 @@ import { redeemPlatformProfileRewardCode, watchWechatPlatformProfileRechargeOrder, } from '../../services/platform-entry/platformProfileClient'; +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; import { type CopyFeedbackState, useCopyFeedback, @@ -354,6 +355,45 @@ export function usePlatformProfileCenterController({ const pendingWechatRechargeOrderIdRef = useRef(null); const confirmingWechatRechargeOrderIdRef = useRef(null); const rechargeCenterReadRevisionRef = useRef(0); + const currentUserId = currentUser?.id ?? ''; + const walletOwnerUserId = usePlatformWalletStore( + (state) => state.ownerUserId, + ); + const storedMudPointBalance = usePlatformWalletStore( + (state) => state.mudPointBalance, + ); + const applyWalletBalanceSnapshot = usePlatformWalletStore( + (state) => state.applyWalletBalanceSnapshot, + ); + const onWalletBalanceMayHaveChanged = usePlatformWalletStore( + (state) => state.onWalletBalanceMayHaveChanged, + ); + const walletOwnerMatchesCurrentUser = + Boolean(currentUserId) && walletOwnerUserId === currentUserId; + const mudPointBalance = walletOwnerMatchesCurrentUser + ? storedMudPointBalance + : null; + + useEffect(() => { + rechargeCenterReadRevisionRef.current += 1; + pendingWechatRechargeOrderIdRef.current = null; + confirmingWechatRechargeOrderIdRef.current = null; + setRechargeCenter(null); + setIsLoadingRechargeCenter(false); + setRechargeError(null); + setRechargePaymentResult(null); + setWechatRechargeOrderConfirmationState(null); + setNativeWechatPayment(null); + setSubmittingRechargeProductId(null); + setIsWalletLedgerOpen(false); + setWalletLedger(null); + setWalletLedgerError(null); + setIsLoadingWalletLedger(false); + setIsRewardCodeOpen(false); + setRewardCodeInput(''); + setRewardCodeError(null); + setRewardCodeSuccess(null); + }, [currentUserId]); // 中文注释:支持带邀请码 query 的直达场景,登录成功后自动打开兑换面板并复用同一套输入状态。 useEffect(() => { @@ -377,18 +417,36 @@ export function usePlatformProfileCenterController({ }, [currentUser, pendingProfileInviteCode, requestLogin]); const loadWalletLedger = useCallback(() => { + const snapshotOwnerUserId = currentUserId; setWalletLedgerError(null); setIsLoadingWalletLedger(true); void getPlatformProfileWalletLedger() - .then(setWalletLedger) + .then((ledger) => { + if ( + usePlatformWalletStore.getState().ownerUserId === snapshotOwnerUserId + ) { + setWalletLedger(ledger); + } + }) .catch((error: unknown) => { + if ( + usePlatformWalletStore.getState().ownerUserId !== snapshotOwnerUserId + ) { + return; + } setWalletLedger(null); setWalletLedgerError( error instanceof Error ? error.message : '读取泥点账单失败', ); }) - .finally(() => setIsLoadingWalletLedger(false)); - }, []); + .finally(() => { + if ( + usePlatformWalletStore.getState().ownerUserId === snapshotOwnerUserId + ) { + setIsLoadingWalletLedger(false); + } + }); + }, [currentUserId]); const openWalletLedgerPanel = useCallback(() => { setIsWalletLedgerOpen(true); @@ -396,13 +454,30 @@ export function usePlatformProfileCenterController({ }, [loadWalletLedger]); const applyRechargeCenter = useCallback( - (center: ProfileRechargeCenterResponse) => { + (center: ProfileRechargeCenterResponse, refreshWallet = false) => { + if ( + !currentUserId || + usePlatformWalletStore.getState().ownerUserId !== currentUserId + ) { + return false; + } rechargeCenterReadRevisionRef.current += 1; setIsLoadingRechargeCenter(false); setRechargeError(null); setRechargeCenter(center); + if (center.mudPointBalance) { + applyWalletBalanceSnapshot(currentUserId, center.mudPointBalance); + } + if (refreshWallet || !center.mudPointBalance) { + void onWalletBalanceMayHaveChanged(); + } + return true; }, - [], + [ + applyWalletBalanceSnapshot, + currentUserId, + onWalletBalanceMayHaveChanged, + ], ); const loadRechargeCenter = useCallback(() => { @@ -412,7 +487,7 @@ export function usePlatformProfileCenterController({ void getPlatformProfileRechargeCenter() .then((center) => { if (revision === rechargeCenterReadRevisionRef.current) { - setRechargeCenter(center); + applyRechargeCenter(center); } }) .catch((error: unknown) => { @@ -429,7 +504,7 @@ export function usePlatformProfileCenterController({ setIsLoadingRechargeCenter(false); } }); - }, []); + }, [applyRechargeCenter]); const refreshRechargeState = useCallback(() => { loadRechargeCenter(); @@ -473,7 +548,9 @@ export function usePlatformProfileCenterController({ .then((response) => { const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; - applyRechargeCenter(response.center); + if (!applyRechargeCenter(response.center, true)) { + return; + } pendingWechatRechargeOrderIdRef.current = null; confirmingWechatRechargeOrderIdRef.current = null; setWechatRechargeOrderConfirmationState(null); @@ -540,7 +617,9 @@ export function usePlatformProfileCenterController({ .then((response) => { const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; - applyRechargeCenter(response.center); + if (!applyRechargeCenter(response.center, true)) { + return; + } pendingWechatRechargeOrderIdRef.current = null; confirmingWechatRechargeOrderIdRef.current = null; setWechatRechargeOrderConfirmationState(null); @@ -616,7 +695,9 @@ export function usePlatformProfileCenterController({ .then(async (response) => { if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) { pendingWechatRechargeOrderIdRef.current = response.order.orderId; - applyRechargeCenter(response.center); + if (!applyRechargeCenter(response.center, true)) { + return; + } const paymentHandled = await requestHostPayment({ payload: response.wechatMiniProgramPayParams, orderId: response.order.orderId, @@ -628,7 +709,9 @@ export function usePlatformProfileCenterController({ } if (paymentChannel === WECHAT_JSAPI_PAYMENT_CHANNEL) { pendingWechatRechargeOrderIdRef.current = response.order.orderId; - applyRechargeCenter(response.center); + if (!applyRechargeCenter(response.center, true)) { + return; + } setRechargePaymentResult({ kind: 'pending', title: '正在打开微信支付', @@ -648,7 +731,9 @@ export function usePlatformProfileCenterController({ confirmResponse.order, ); const isPaid = result.kind === 'success'; - applyRechargeCenter(confirmResponse.center); + if (!applyRechargeCenter(confirmResponse.center, true)) { + return; + } setRechargePaymentResult(result); if (result.kind !== 'pending') { pendingWechatRechargeOrderIdRef.current = null; @@ -672,7 +757,9 @@ export function usePlatformProfileCenterController({ throw new Error('微信 H5 支付链接生成失败'); } pendingWechatRechargeOrderIdRef.current = response.order.orderId; - applyRechargeCenter(response.center); + if (!applyRechargeCenter(response.center, true)) { + return; + } setRechargePaymentResult({ kind: 'pending', title: '正在打开微信支付', @@ -689,7 +776,9 @@ export function usePlatformProfileCenterController({ throw new Error('微信 Native 支付链接生成失败'); } pendingWechatRechargeOrderIdRef.current = response.order.orderId; - applyRechargeCenter(response.center); + if (!applyRechargeCenter(response.center, true)) { + return; + } setNativeWechatPayment({ ...wechatNativePayment, codeUrl, @@ -778,7 +867,9 @@ export function usePlatformProfileCenterController({ } const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; - applyRechargeCenter(response.center); + if (!applyRechargeCenter(response.center, true)) { + return; + } if (result.kind !== 'pending') { setNativeWechatPayment(null); pendingWechatRechargeOrderIdRef.current = null; @@ -839,7 +930,9 @@ export function usePlatformProfileCenterController({ } const result = buildRechargePaymentResultForOrder(response.order); - applyRechargeCenter(response.center); + if (!applyRechargeCenter(response.center, true)) { + return; + } if (result.kind === 'pending') { await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS); continue; @@ -1034,6 +1127,7 @@ export function usePlatformProfileCenterController({ setReferralCenter(response.center); setReferralRedeemCode(''); setReferralSuccess('已填写'); + void onWalletBalanceMayHaveChanged(); void onRechargeSuccess?.(); }) .catch((error: unknown) => { @@ -1042,7 +1136,12 @@ export function usePlatformProfileCenterController({ ); }) .finally(() => setIsSubmittingReferralRedeem(false)); - }, [isSubmittingReferralRedeem, onRechargeSuccess, referralRedeemCode]); + }, [ + isSubmittingReferralRedeem, + onRechargeSuccess, + onWalletBalanceMayHaveChanged, + referralRedeemCode, + ]); const submitRewardCode = useCallback(() => { if (isSubmittingRewardCode || !rewardCodeInput.trim()) { @@ -1056,13 +1155,30 @@ export function usePlatformProfileCenterController({ .then((response: RedeemProfileRewardCodeResponse) => { setRewardCodeInput(''); setRewardCodeSuccess(`已到账 ${response.amountGranted} 泥点`); + void onWalletBalanceMayHaveChanged(); void onRechargeSuccess?.(); }) .catch((error: unknown) => { setRewardCodeError(error instanceof Error ? error.message : '兑换失败'); }) .finally(() => setIsSubmittingRewardCode(false)); - }, [isSubmittingRewardCode, onRechargeSuccess, rewardCodeInput]); + }, [ + isSubmittingRewardCode, + onRechargeSuccess, + onWalletBalanceMayHaveChanged, + rewardCodeInput, + ]); + + const rechargeModalCenter = useMemo(() => { + if (!walletOwnerMatchesCurrentUser || !rechargeCenter) { + return null; + } + return { + ...rechargeCenter, + walletBalance: mudPointBalance?.totalPoints ?? 0, + mudPointBalance: mudPointBalance ?? undefined, + }; + }, [mudPointBalance, rechargeCenter, walletOwnerMatchesCurrentUser]); return { closeNativeWechatPayment, @@ -1086,7 +1202,7 @@ export function usePlatformProfileCenterController({ openRewardCodeModal, openWalletLedgerPanel, profilePopupPanel, - rechargeCenter, + rechargeCenter: rechargeModalCenter, rechargeError, rechargePaymentResult, referralCenter, diff --git a/src/services/platform-entry/platformProfileClient.ts b/src/services/platform-entry/platformProfileClient.ts index 7164a8118..98a7e1ea7 100644 --- a/src/services/platform-entry/platformProfileClient.ts +++ b/src/services/platform-entry/platformProfileClient.ts @@ -142,10 +142,11 @@ export function revokePlatformProfileExternalApiKey( export function getPlatformProfileRechargeCenter( options: PlatformProfileRequestOptions = {}, + signal?: AbortSignal, ) { return requestPlatformProfileJson( '/recharge-center', - { method: 'GET' }, + { method: 'GET', signal }, '读取泥点购买信息失败', options, ); diff --git a/src/stores/usePlatformWalletStore.ts b/src/stores/usePlatformWalletStore.ts new file mode 100644 index 000000000..fbea92a11 --- /dev/null +++ b/src/stores/usePlatformWalletStore.ts @@ -0,0 +1,52 @@ +import { useEffect } from 'react'; + +import { createProfileWalletStore } from '@/packages/shared/src'; +import { getPlatformProfileRechargeCenter } from '@/src/services/platform-entry/platformProfileClient.ts'; + +export const usePlatformWalletStore = createProfileWalletStore({ + getRechargeCenter: (signal) => + getPlatformProfileRechargeCenter({}, signal), +}); + +export function usePlatformWalletLifecycle( + currentUserId: string | null, + canAccessProtectedData: boolean, +) { + const setWalletOwner = usePlatformWalletStore( + (state) => state.setWalletOwner, + ); + const onWalletBalanceMayHaveChanged = usePlatformWalletStore( + (state) => state.onWalletBalanceMayHaveChanged, + ); + + useEffect(() => { + const ownerUserId = + canAccessProtectedData && currentUserId ? currentUserId : null; + setWalletOwner(ownerUserId); + if (!ownerUserId) { + return undefined; + } + + void onWalletBalanceMayHaveChanged(); + const refreshWallet = () => { + void onWalletBalanceMayHaveChanged(); + }; + const refreshVisibleWallet = () => { + if (document.visibilityState === 'visible') { + refreshWallet(); + } + }; + + window.addEventListener('focus', refreshWallet); + document.addEventListener('visibilitychange', refreshVisibleWallet); + return () => { + window.removeEventListener('focus', refreshWallet); + document.removeEventListener('visibilitychange', refreshVisibleWallet); + }; + }, [ + canAccessProtectedData, + currentUserId, + onWalletBalanceMayHaveChanged, + setWalletOwner, + ]); +} diff --git a/vitest.config.ts b/vitest.config.ts index 844370c57..ca9e179c6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -74,6 +74,7 @@ export default defineConfig({ 'packages/shared/src/contracts/hostBridge.test.ts', 'packages/shared/src/components/**/*.test.ts', 'packages/shared/src/components/**/*.test.tsx', + 'packages/shared/src/stores/**/*.test.ts', 'packages/shared/src/utils/**/*.test.ts', ], exclude: [