diff --git a/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx b/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx index e52df521a..3a9933491 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx @@ -11,7 +11,10 @@ export function AccountWalletBar({ return (
controller.setWalletLedgerOpen(false)} 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..ced080911 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 @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { PlatformProfileRechargeNativePaymentState } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal'; import type { @@ -17,10 +17,15 @@ import { useWalletStore } from '../../stores/useWalletStore'; export function useAccountWallet(currentUserId: string) { const { + ownerUserId, mudPointBalance, + legacyWalletBalance, mudPointBalanceStatus, mudPointBalanceError, + setWalletOwner, + captureWalletBalanceSnapshot, applyWalletBalanceSnapshot, + applyLegacyWalletBalanceSnapshot, onWalletBalanceMayHaveChanged, resetWalletBalance, } = useWalletStore(); @@ -41,32 +46,98 @@ export function useAccountWallet(currentUserId: string) { const [nativeRechargePayment, setNativeRechargePayment] = useState(null); const rechargeLifecycleRef = useRef(0); + const walletLedgerLifecycleRef = useRef(0); + const [walletUiOwnerUserId, setWalletUiOwnerUserId] = useState(currentUserId); + const currentUserIdRef = useRef(currentUserId); + const walletOwnerMatchesCurrentUser = + Boolean(currentUserId) && ownerUserId === currentUserId; + const walletUiOwnerMatchesCurrentUser = + Boolean(currentUserId) && walletUiOwnerUserId === currentUserId; + const walletUiIsVisible = + walletOwnerMatchesCurrentUser && walletUiOwnerMatchesCurrentUser; + const visibleMudPointBalance = walletOwnerMatchesCurrentUser + ? mudPointBalance + : null; + const visibleLegacyWalletBalance = walletOwnerMatchesCurrentUser + ? legacyWalletBalance + : null; + const visibleMudPointBalanceStatus = walletOwnerMatchesCurrentUser + ? mudPointBalanceStatus + : 'idle'; + const visibleMudPointBalanceError = walletOwnerMatchesCurrentUser + ? mudPointBalanceError + : ''; + const visibleWalletBalance = + visibleMudPointBalance?.totalPoints ?? visibleLegacyWalletBalance; + + useLayoutEffect(() => { + currentUserIdRef.current = currentUserId; + rechargeLifecycleRef.current += 1; + walletLedgerLifecycleRef.current += 1; + }, [currentUserId]); 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(() => { + setWalletUiOwnerUserId(currentUserId); + 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 walletLedgerLifecycle = ++walletLedgerLifecycleRef.current; + const snapshotOwnerUserId = currentUserId; setWalletLedgerLoading(true); setWalletLedgerError(null); try { - setWalletLedger(await getClientProfileWalletLedger()); + const ledger = await getClientProfileWalletLedger(); + if ( + walletLedgerLifecycleRef.current === walletLedgerLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { + setWalletLedger(ledger); + } } catch (error) { + if ( + walletLedgerLifecycleRef.current !== walletLedgerLifecycle || + currentUserIdRef.current !== snapshotOwnerUserId + ) { + return; + } setWalletLedger(null); setWalletLedgerError( error instanceof Error ? error.message : '读取泥点账单失败', ); } finally { - setWalletLedgerLoading(false); + if ( + walletLedgerLifecycleRef.current === walletLedgerLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { + setWalletLedgerLoading(false); + } } } @@ -75,37 +146,55 @@ export function useAccountWallet(currentUserId: string) { void loadWalletLedger(); } - function applyRechargeContent(center: ProfileRechargeCenterResponse) { + function applyRechargeContent( + walletSnapshot: ReturnType, + center: ProfileRechargeCenterResponse, + ) { const { walletBalance, mudPointBalance: nextMudPointBalance, ...content } = center; - void walletBalance; - if (nextMudPointBalance) { - applyWalletBalanceSnapshot(nextMudPointBalance); + if (nextMudPointBalance && walletSnapshot) { + applyWalletBalanceSnapshot(walletSnapshot, nextMudPointBalance); + } else if (walletSnapshot && Number.isFinite(walletBalance)) { + applyLegacyWalletBalanceSnapshot(walletSnapshot, walletBalance); + void onWalletBalanceMayHaveChanged(); + } else { + void onWalletBalanceMayHaveChanged(); } setRechargeContent(content); } async function loadRechargeCenter() { + const snapshotOwnerUserId = currentUserId; + const walletSnapshot = captureWalletBalanceSnapshot(snapshotOwnerUserId); const rechargeLifecycle = rechargeLifecycleRef.current; setRechargeLoading(true); setRechargeError(null); try { const center = await getClientProfileRechargeCenter(); - if (rechargeLifecycleRef.current !== rechargeLifecycle) { + if ( + rechargeLifecycleRef.current !== rechargeLifecycle || + currentUserIdRef.current !== snapshotOwnerUserId + ) { return; } - applyRechargeContent(center); + applyRechargeContent(walletSnapshot, center); } catch (error) { - if (rechargeLifecycleRef.current === rechargeLifecycle) { + if ( + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { setRechargeError( error instanceof Error ? error.message : '读取泥点购买信息失败', ); } } finally { - if (rechargeLifecycleRef.current === rechargeLifecycle) { + if ( + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { setRechargeLoading(false); } } @@ -130,16 +219,21 @@ export function useAccountWallet(currentUserId: string) { return; } const rechargeLifecycle = rechargeLifecycleRef.current; + const snapshotOwnerUserId = currentUserId; + const walletSnapshot = captureWalletBalanceSnapshot(snapshotOwnerUserId); setSubmittingRechargeProductId(product.productId); setRechargeError(null); try { const response = await createClientProfileRechargeOrder( product.productId, ); - if (rechargeLifecycleRef.current !== rechargeLifecycle) { + if ( + rechargeLifecycleRef.current !== rechargeLifecycle || + currentUserIdRef.current !== snapshotOwnerUserId + ) { return; } - applyRechargeContent(response.center); + applyRechargeContent(walletSnapshot, response.center); const nativePayment = response.wechatNativePayment; const codeUrl = nativePayment?.codeUrl?.trim(); const expiresAt = nativePayment?.expiresAt?.trim(); @@ -155,11 +249,17 @@ export function useAccountWallet(currentUserId: string) { isConfirming: false, }); } catch (error) { - if (rechargeLifecycleRef.current === rechargeLifecycle) { + if ( + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { setRechargeError(error instanceof Error ? error.message : '充值失败'); } } finally { - if (rechargeLifecycleRef.current === rechargeLifecycle) { + if ( + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { setSubmittingRechargeProductId(null); } } @@ -170,6 +270,8 @@ export function useAccountWallet(currentUserId: string) { return; } const rechargeLifecycle = rechargeLifecycleRef.current; + const snapshotOwnerUserId = currentUserId; + const walletSnapshot = captureWalletBalanceSnapshot(snapshotOwnerUserId); const orderId = nativeRechargePayment.orderId; setNativeRechargePayment((current) => current?.orderId === orderId @@ -178,10 +280,13 @@ export function useAccountWallet(currentUserId: string) { ); try { const response = await confirmClientWechatProfileRechargeOrder(orderId); - if (rechargeLifecycleRef.current !== rechargeLifecycle) { + if ( + rechargeLifecycleRef.current !== rechargeLifecycle || + currentUserIdRef.current !== snapshotOwnerUserId + ) { return; } - applyRechargeContent(response.center); + applyRechargeContent(walletSnapshot, response.center); if (response.order.status === 'paid') { setNativeRechargePayment(null); void onWalletBalanceMayHaveChanged(); @@ -199,7 +304,10 @@ export function useAccountWallet(currentUserId: string) { : current, ); } catch { - if (rechargeLifecycleRef.current === rechargeLifecycle) { + if ( + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { setNativeRechargePayment((current) => current?.orderId === orderId ? { @@ -214,29 +322,35 @@ export function useAccountWallet(currentUserId: string) { } return { - mudPointBalance, - mudPointBalanceStatus, - mudPointBalanceError, + ownerUserId, + mudPointBalance: visibleMudPointBalance, + legacyWalletBalance: visibleLegacyWalletBalance, + mudPointBalanceStatus: visibleMudPointBalanceStatus, + mudPointBalanceError: visibleMudPointBalanceError, onWalletBalanceMayHaveChanged, resetWalletBalance, - walletLedgerOpen, + walletLedgerOpen: walletUiIsVisible && walletLedgerOpen, setWalletLedgerOpen, - walletLedger, - walletLedgerLoading, - walletLedgerError, - rechargeOpen, + walletLedger: walletUiIsVisible ? walletLedger : null, + walletLedgerLoading: walletUiIsVisible && walletLedgerLoading, + walletLedgerError: walletUiIsVisible ? walletLedgerError : null, + rechargeOpen: walletUiIsVisible && rechargeOpen, rechargeModalCenter: - rechargeContent && mudPointBalance + rechargeContent && visibleWalletBalance !== null ? { ...rechargeContent, - walletBalance: mudPointBalance.totalPoints, - mudPointBalance, + walletBalance: visibleWalletBalance, + ...(visibleMudPointBalance + ? { mudPointBalance: visibleMudPointBalance } + : {}), } : null, - rechargeLoading, - rechargeError, - submittingRechargeProductId, - nativeRechargePayment, + rechargeLoading: walletUiIsVisible && rechargeLoading, + rechargeError: walletUiIsVisible ? rechargeError : null, + submittingRechargeProductId: walletUiIsVisible + ? submittingRechargeProductId + : null, + nativeRechargePayment: walletUiIsVisible ? nativeRechargePayment : null, setNativeRechargePayment, loadWalletLedger, openWalletLedger, 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/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 1ff1f578f..9a6bb3945 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -303,13 +303,23 @@ export function registerClientHomeTests() { ([command]) => command === 'inspect_local_project_directory', ).length; + await waitFor(() => { + expect(runtimeHarness.listen).toHaveBeenCalledWith( + 'game-creator-manifest-invalidated', + expect.any(Function), + ); + }); manifestChanged = true; act(() => { runtimeHarness.emitManifestInvalidated('art-asset-plan'); }); expect( - await screen.findByRole('button', { name: /runtime-live-hero\.png/ }), + await screen.findByRole( + 'button', + { name: /runtime-live-hero\.png/ }, + { timeout: 5_000 }, + ), ).not.toBeNull(); expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); @@ -359,9 +369,7 @@ export function registerClientHomeTests() { source: { kind: 'generated' }, }, ]; - let resolveStaleRefresh!: ( - manifest: typeof staleFirstManifest, - ) => void; + let resolveStaleRefresh!: (manifest: typeof staleFirstManifest) => void; const staleRefresh = new Promise((resolve) => { resolveStaleRefresh = resolve; }); diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index 7a573f945..b3b42fad8 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -1,14 +1,20 @@ /** @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 { useAccountWallet } from '../src/features/app-shell/useAccountWallet'; import { useWalletStore } from '../src/stores/useWalletStore'; function detailedBalance(totalPoints: number) { @@ -26,10 +32,11 @@ function detailedBalance(totalPoints: number) { describe('useWalletStore', () => { beforeEach(() => { useWalletStore.getState().resetWalletBalance(); + useWalletStore.getState().setWalletOwner('user-a'); vi.clearAllMocks(); }); - test('keeps mudPointBalance as the only balance state', async () => { + test('keeps mudPointBalance as the authoritative detailed balance state', async () => { const mudPointBalance = detailedBalance(120); clientApi.getClientProfileRechargeCenter.mockResolvedValue({ walletBalance: 120, @@ -42,15 +49,21 @@ describe('useWalletStore', () => { }); expect(result.current.mudPointBalance).toEqual(mudPointBalance); + expect(result.current.legacyWalletBalance).toBe(120); expect(result.current.mudPointBalanceStatus).toBe('ready'); expect(result.current.mudPointBalanceError).toBe(''); expect(Object.keys(result.current).sort()).toEqual([ + 'applyLegacyWalletBalanceSnapshot', 'applyWalletBalanceSnapshot', + 'captureWalletBalanceSnapshot', + 'legacyWalletBalance', 'mudPointBalance', 'mudPointBalanceError', 'mudPointBalanceStatus', 'onWalletBalanceMayHaveChanged', + 'ownerUserId', 'resetWalletBalance', + 'setWalletOwner', ]); }); @@ -61,7 +74,12 @@ describe('useWalletStore', () => { await useWalletStore.getState().onWalletBalanceMayHaveChanged(); const mudPointBalance = detailedBalance(120); - useWalletStore.getState().applyWalletBalanceSnapshot(mudPointBalance); + const snapshot = useWalletStore + .getState() + .captureWalletBalanceSnapshot('user-a'); + useWalletStore + .getState() + .applyWalletBalanceSnapshot(snapshot!, mudPointBalance); expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready'); @@ -79,7 +97,12 @@ describe('useWalletStore', () => { const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged(); const mudPointBalance = detailedBalance(160); - useWalletStore.getState().applyWalletBalanceSnapshot(mudPointBalance); + const snapshot = useWalletStore + .getState() + .captureWalletBalanceSnapshot('user-a'); + useWalletStore + .getState() + .applyWalletBalanceSnapshot(snapshot!, mudPointBalance); rejectRequest?.(new Error('过期请求失败')); await refresh; @@ -96,9 +119,10 @@ describe('useWalletStore', () => { await useWalletStore.getState().onWalletBalanceMayHaveChanged(); expect(useWalletStore.getState().mudPointBalance).toBeNull(); + expect(useWalletStore.getState().legacyWalletBalance).toBe(120); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error'); expect(useWalletStore.getState().mudPointBalanceError).toBe( - '充值中心响应缺少泥点余额', + '泥点明细读取失败', ); }); @@ -128,10 +152,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), @@ -156,7 +182,9 @@ describe('useWalletStore', () => { expect(useWalletStore.getState().mudPointBalance?.totalPoints).toBe(90); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error'); - expect(useWalletStore.getState().mudPointBalanceError).toBe('刷新失败'); + expect(useWalletStore.getState().mudPointBalanceError).toBe( + '泥点明细读取失败', + ); }); test('does not restore a late response after reset', async () => { @@ -179,4 +207,148 @@ describe('useWalletStore', () => { expect(useWalletStore.getState().mudPointBalance).toBeNull(); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('idle'); }); + + test('clears immediately when the adapter switches wallet owners', () => { + const snapshot = useWalletStore + .getState() + .captureWalletBalanceSnapshot('user-a'); + useWalletStore + .getState() + .applyWalletBalanceSnapshot(snapshot!, 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', () => { + const snapshot = useWalletStore + .getState() + .captureWalletBalanceSnapshot('user-a'); + useWalletStore + .getState() + .applyWalletBalanceSnapshot(snapshot!, 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), + }); + }); + + 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({ + walletBalance: 18, + mudPointBalance: detailedBalance(18), + }); + clientApi.getClientProfileWalletLedger.mockImplementation( + () => + new Promise((resolve) => { + resolveLedger = resolve; + }), + ); + const { result } = renderHook(() => useAccountWallet('user-a')); + await act(async () => undefined); + + act(() => { + result.current.openWalletLedger(); + result.current.openRecharge(); + }); + expect(result.current.walletLedgerLoading).toBe(true); + + await act(async () => { + resolveLedger({ entries: [] }); + }); + + expect(result.current.walletLedger).toEqual({ entries: [] }); + expect(result.current.walletLedgerLoading).toBe(false); + }); + + test('hides account-owned dialogs and data when the account changes', async () => { + clientApi.getClientProfileRechargeCenter.mockResolvedValue({ + walletBalance: 18, + mudPointBalance: detailedBalance(18), + }); + clientApi.getClientProfileWalletLedger.mockResolvedValue({ entries: [] }); + const { result, rerender } = renderHook( + ({ userId }) => useAccountWallet(userId), + { initialProps: { userId: 'user-a' } }, + ); + await act(async () => undefined); + await act(async () => { + result.current.openWalletLedger(); + result.current.openRecharge(); + }); + expect(result.current.walletLedgerOpen).toBe(true); + expect(result.current.rechargeOpen).toBe(true); + + await act(async () => { + rerender({ userId: 'user-b' }); + }); + + expect(result.current.walletLedgerOpen).toBe(false); + expect(result.current.walletLedger).toBeNull(); + expect(result.current.rechargeOpen).toBe(false); + expect(result.current.nativeRechargePayment).toBeNull(); + }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5c3b47cd9..303550d22 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -135,6 +135,7 @@ - 影响范围:AI 游戏创作 `runtime_driver/task_start.rs`、`task_queue.rs`、自主构建 continuation 合同、Supervisor 进度卡与相应 Rust/AppSurface 回归;不改变 manifest DAG、Agent catalog、Provider 路由或项目产物合同。 - 验证方式:不预占 child locks,真实一次调度三项首波任务,并在有界时间内证明每个逻辑 Run 至少写入 running/`turn.started`;重复调度不得新增逻辑 Run。前端固定时钟覆盖正常运行、子 Agent 新活动、疑似停滞、各类合法等待与 terminal 冻结。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`。 + ## 2026-07-31 图集切片按需编码并批量确认持久化 - 背景:`2026-07-29 图集切片必须受前置容量和有界 CPU 保护` 收口了连通域数量与 CPU 并发,但切片仍在一次循环里全部裁剪并编码,最多 64 份 PNG 字节连同整张 RGBA 同时驻留内存;持久化又按切片逐个调用 procedure,N 片至少 2N 次写入外加一次 cohort 完成,任一片失败都会留下已确认的部分记录。手动拆分入口另有一处重复鉴权:`get_editor_project` 已经取回并定位了来源资源,随后仍走 `parse_editor_reference_image` 按注册 ID 再解析一次,触发全账号项目与素材库扫描。 @@ -147,6 +148,7 @@ - 验证方式:`platform-image` 覆盖 prepare 不编码且 `Send + Sync`、并发编码多个 index 结果不变、累计裁剪像素在编码前拒绝;`api-server` 覆盖切片记录 ID 稳定且按 owner / index 分区、自动路径保留处理超时告警码、上传超时释放内存许可;`spacetime-module` 覆盖批次校验的完整 cohort、重复 objectKey、来源资源同 owner 同 project、部分 cohort 拒绝与重放只在内容一致时复用。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、本文件 `2026-07-29 图集切片必须受前置容量和有界 CPU 保护`。 - 补记说明:本条为事后补写,记录提交 `cf1a02312` 已落地的行为,不改变其任何决策。 + ## 2026-07-31 AI 游戏创作资源依赖图采用 Rust 只读拓扑与前端派生 SVG > 状态:其中资源卡 Pointer Move 拖动预览与局部更新验收已由 2026-08-03 mentor 最新决定暂缓;只读拓扑、SVG 派生展示、搜索与选择高亮合同继续生效。 @@ -1236,7 +1238,7 @@ ## 2026-06-18 图片画布 Seedance 2.0 参考媒体提交边界 - 背景:`/editor/canvas` 生成视频需要严格对齐火山 Seedance 2.0 多模态参考输入;参考视频若继续走 Base64 / `data:video` 会超过请求体并被上游拒绝,参考音频单独输入和非 Seedance 模型携带参考字段也会违反文档契约。 -- 决策:仅 `seedance2.0-fast` / `seedance2.0` 可提交参考图片、参考视频、参考音频;图片 0~9、视频 0~3、音频 0~3,音频必须搭配图片或视频。参考视频只能提交公网 URL、`asset://` 或画板资源 `objectKey`,禁止 `data:video/*`;视频 / 音频上传先走 OSS 直传和 asset*object confirm,前端保存 signed URL 预览但提交优先 `objectKey`,后端统一重新签名给 Ark。Ark body 按 `image_url` / `video_url` / `audio_url` + `reference*\*`role 构造,并显式发送`generate_audio:false`。 +- 决策:仅 `seedance2.0-fast` / `seedance2.0` 可提交参考图片、参考视频、参考音频;图片 `0~9`、视频 `0~3`、音频 `0~3`,音频必须搭配图片或视频。参考视频只能提交公网 URL、`asset://` 或画板资源 `objectKey`,禁止 `data:video/*`;视频 / 音频上传先走 OSS 直传和 asset*object confirm,前端保存 signed URL 预览但提交优先 `objectKey`,后端统一重新签名给 Ark。Ark body 按 `image_url` / `video_url` / `audio_url` + `reference*\*`role 构造,并显式发送`generate_audio:false`。 - 影响范围:图片画布生成视频面板、参考媒体上传工作流、`editorReferenceUploadClient`、`ImageCanvasGenerationSubmissionModel`、`shared-contracts`、`api-server` 编辑器视频 BFF、Lovart 生成类面板文档。 - 验证方式:运行 `npx vitest run src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/services/image-editor/editorReferenceUploadClient.test.ts --reporter verbose`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts editor_video_request_supports_seedance_multimodal_references --manifest-path server-rs/Cargo.toml`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、火山 Seedance 2.0 任务创建文档。 @@ -6055,6 +6057,7 @@ - 占位删除与重试:completion 必须读取当前权威 dialog;若删除已先持久化,只跳过画布 layer / dialog 写回,不得使用请求中的旧 placeholder 复活图层,已经成功持久化的 project resource / 账号素材允许保留。若回包时本地占位已删除,前端不得应用完成快照或写历史;现有布局 CAS 没有 deletion tombstone,因此 completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口,绝对“删除意图胜出”留待 targeted delete / tombstone 方案。该路由是 unsafe POST,客户端不得配置 `EDITOR_REQUEST_RETRY_OPTIONS`;请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放,Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照,由用户显式决定是否再次执行。 - 历史边界:成功加入画布时写一条 `perfect-pixel` 历史,中文标签为“完美像素”,并纳入新增结果保护;撤销不得让派生 PNG 消失。像素处理失败或 completion 因占位删除未落画布时不写该历史。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`。 + ## 2026-07-31 game-chat 每条输出入聊天、试玩后收束与平台图集引用 - 背景:game-chat 的 ready response 之前只作为 transient stream 展示,专业 Agent 的 `final-reply` 只进入各自私有 conversation,刷新或事件 / 轮询重放时项目聊天可能丢失这些输出;自主构建完成后仍可能继续进入发布任务;配置 External Editor API 时,原型 HTML 也可能不实际使用平台生成的 Canvas 美术资源。 @@ -6303,6 +6306,7 @@ - 影响范围:`/editor/canvas` 的 `audio-background-music` 面板撤销按钮与其定向测试;不改变单层交换快照语义、canonicalization、提交锁、Suno 契约或后端 Prompt 助手,也不修改状态模型字段,`temporaryPromptSnapshot` 已在公开 dialog 状态中且只在 `completing` / `simplifying` 期间非空。本条不适用于 SFX,V1.0 不改动 SFX 的一键优化与撤销行为。 - 验证方式:按矩阵逐行覆盖初始隐藏、首次与再次 AI 处理期间显示并禁用、成功启用、失败隐藏、手动编辑后仍启用、点击预设隐藏、`submitting` 有无快照的两种表现、解除锁定后恢复,以及连续撤销互换保持启用;并断言处理期间按钮仍在可访问树中且为真实禁用态。 - 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。 + ## 2026-08-03 完美像素对账判据改看 dialog 收口状态,网关合成响应归入未知结果 - 缺陷一(对账把真成功判成失败):对账用「同 ID 的 generation-dialog 是否还在权威快照里」判定成败,而服务端成功回填时**保留**该 dialog 并就地改写——`apply_editor_canvas_generation_items` 置 `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`,该行为另有服务端测试断言 `dialog["generatedLayerId"]` 钉住。所以响应丢失但服务端其实已完成时,判据反向:用户被告知「画布未收到完美像素结果,请确认素材库」,而结果早已在画布上,重做一遍就造出第二份;这条分支还刻意不套用快照,本地也看不到那个新图层。 @@ -6476,6 +6480,22 @@ - 权威性与剩余风险:preflight 不创建锁、reservation 或新表记录;最终 `persist_editor_pixel_art_result_and_return` 仍在同一事务内重复目录、布局、幂等 identity 和 revision 校验。preflight 通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;彻底消除该 TOCTOU 需要 durable reservation / journal 或事务协调,不在本 PR 的最小修复边界内。 - 契约影响:只新增 SpacetimeDB procedure ABI 与生成 bindings;没有表字段、index、migration、HTTP DTO、路由、状态码、OpenAPI 或 shared-contracts 变化。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.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` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。 +- 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、结束新请求或刷新新账号钱包。充值下单、邀请码兑换和奖励码兑换三条写请求还必须共享账号生命周期 `AbortController`,在切号 effect cleanup 与卸载时中止旧 signal,使 `fetchWithApiAuth` 的 refresh 等待和写请求退避立即结束,禁止旧 POST 重试重新读取新账号 Token。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`;owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。 +- 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-07 refresh 发布隔离补充:`apiClient` 把公开 token 设置与清理视为认证代际变更,共享 `/api/auth/refresh` 只在“代际 + 发起时 token”快照相同时复用。refresh 成功只能以同一快照 CAS 发布新 token,旧账号晚到成功必须拒绝;旧 refresh 的 401/403 也只能在原快照仍当前时清 token。新账号进入新代际后立即发起独立 refresh,不等待也不加入旧账号 Promise。 +- 2026-08-05 生成扣退费时序补充:external generation 入队时尚未扣费,worker 领取为 `running` 后的资产操作才预扣,业务失败则先退款再写任务失败态。主站钱包因此以账号下全局 active external task 为轮询生命周期,每轮成功状态读取都通知共享 Store 合并刷新,终态轮同时覆盖成功结算与失败退款;画布内容刷新仍只限当前项目的 `completed`,不因其它项目或 `failed` 刷新画布。 +- 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。 +- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 + ## 2026-08-04 AI 游戏项目 manifest 存储与工作台实时投影 - 存储决策:`.agent/manifest.json` 的版本追加不可变约束由同目录持久专用锁保护,读取旧状态、校验版本前缀、安装临时文件和安装后回读必须处于同一临界区;进程内 Mutex 不能替代跨进程文件锁。 @@ -6535,6 +6555,7 @@ - classic script 分析单元把 inline 与无 `defer / async` 的本地 external 正文按 `game/index.html` 标签顺序交错组成 parser-blocking 段,再把 classic external `defer` 按文档顺序放到解析完成后的 deferred 段;不得把 defer-before-inline 误投影为外链先执行。classic external `async` 的下载完成顺序不可静态证明,当前静态门直接失败关闭。带 `src` 标签的 inline body 继续忽略;外部文件仍执行可信普通文件、`game/` 边界、文件数与累计体积门禁,重复标签按浏览器出现次数保留求值位置。 - Canvas 尺寸、可见性、元素绑定和 stylesheet 选择器扫描只消费浏览器可渲染标记;`template / textarea / noscript / title / style / xmp / iframe / noembed / plaintext` 内的 Canvas、标签和样式诱饵全部跳过。活动顶层 stylesheet 与可见标记分开提取,既允许真实 CSS 参与隐藏/尺寸判断,也不把 CSS raw-text 中的伪标签当作 DOM。 - ESM 组合单元按 dependency 初始化先于 importer 顶层求值排列。import reference 的 span replacement 仍基于原 importer 完成,随后把已闭包的 dependency projection 放在 importer 前并对最终单元重跑 parser、semantic、单元 `2 MiB` 与累计投影 `32 MiB` 门禁;循环模块继续按 `(origin module, original root binding)` canonical identity 去重并要求有界固定点收敛。 + ## 2026-08-04 JavaScript 延迟状态与复合调用边 - 受控异步 callback 的 alias 读取按完整 enclosing invocation 链延迟到各层函数同步收尾,最外层再延迟到当前 job 末尾;callback 写入仍不在注册点同步提交。conditional / assignment expression callee 分别在 test / RHS 求值后建立调用边,`new` 同时执行普通 function constructor 及 alias。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3035f02ce..e4ac9b59b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -94,6 +94,7 @@ - 处理:旧 action 继续禁止重放或伪造 observation;旧 child 与父 Run 先真实终态。新 Supervisor continuation 仅扫描同 Session、同 source、同有效任务合同的历史根 Run,并要求对应 ready-task 同时存在 `failed / needs-reconciliation` 记录、最终 `cancelled` 记录和 durable cancel tombstone,才把当前 manifest 的同一 failed 节点恢复为 pending,让 scheduler 创建新 child Run。manifest 的读取、筛选、child 证据重验和写回放在同一项目写锁内;每个 task journal 只读取一次并按 parent Run 建索引。较新的无 child Run 默认阻断旧凭证,只有其 root journal 精确证明为旧 failed Graph 在进入 scheduler 前即失败时才允许向前查找;scheduler 自身失败不得被当成该兼容场景。 - 验证:构造 reconciliation child、人工 cancel tombstone、failed manifest 和终态父 Run,证明同源 continuation 只重排该节点;并列普通 failed 节点保持 failed,完成合同继续继承原任务 SHA 与项目 baseline,旧 pending action 不恢复。追加覆盖“旧 failed Graph 未调度”的中间 Run 可以跨过,而较新的 scheduler failure 即使没有 child journal 也会阻断更老 tombstone。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs`。 + ## `timeout_at` 不能替代显式的预算耗尽预检 - 现象:给完美像素加端点级并发闸后,预算已经耗尽的请求仍然能拿到许可,白占一个名额继续去打几轮全账号 SpacetimeDB 扫描,直到下载那步才失败。 @@ -109,6 +110,7 @@ - 处理:把递增封进一个 guard 结构体,递减放在它的 `Drop` 实现里;递增本身用 `fetch_update` 的 CAS,不能用「先读后加」——两个线程同时读到 `max - 1` 各自加一就会越界。拿到资源后立即 `drop(guard)` 让出队列名额,不要让它跟着许可一起活到请求结束。 - 验证:单测覆盖 CAS 边界(满了返回失败且计数不越界、上限为 0 时任何进入都失败),并由独立用例覆盖 guard 离开作用域后的计数归还。预算耗尽路径只断言 `504`,不得通过另一个测试也会修改的进程级 static before/after 来推断“未入队”,也不得用串行锁或 `--test-threads=1` 掩盖隔离问题。 - 关联:`server-rs/crates/api-server/src/editor_project.rs`(`try_enter_bounded_queue`、`EditorPixelArtSnapQueueGuard`)。 + ## Linux 生产脚本门禁不能假设本地也是 GNU userland - 现象:macOS 本地运行维护页、生产 API 部署和 Rust 产物门禁时,依次出现 `mv: illegal option -- T`、`mapfile: command not found`、`/usr/bin/cp` / `/usr/bin/chmod` 不存在,以及 `.rlib` 明明含有 `.o` 却报告“没有可扫描成员”;安全修复计划还会把 `/var/folders` 到 `/private/var/folders` 的系统别名误判为用户符号链接。 @@ -298,7 +300,7 @@ - 现象:测试点击“添加素材”后,图层状态已经写入,但立即用 `getByAltText('画布图片:...')` 偶发或稳定找不到图片;前一张图可能通过,紧接着添加的第二张失败。 - 原因:带 `objectKey` 的画布图片通过 `useResolvedAssetReadUrl` 异步获取签名 URL,`resolvedUrl` 就绪前不会渲染带 `alt` 的 ``。`user.click` 只等待点击交互完成,不等待 effect 内的换签 Promise;前一张图在后续操作期间出现只是调度时机,不是同步契约。 -- 处理:每次点击添加后分别用 `await screen.findByAltText(...)` 等待对应图片可见,再执行依赖该图层的下一步操作;不要用固定 sleep,也不要只等待最后一张图而让前面的断言依赖偶然调度。 +- 处理:每次点击添加后分别用 `await screen.findByAltText(...)` 等待对应图片可见,再执行依赖该图层的下一步操作;不要用固定 sleep,也不要只等待最后一张图而让前面的断言依赖偶然调度。完整前端回归并行负载较高时,可只对明确跨越换签 Promise 的目标查询设置局部、有界的 `5_000ms` 超时,不要放宽 Testing Library 全局超时。 - 验证:先精确运行目标用例并连续重复,再运行所在测试文件和完整前端测试;删除场景仍要保留 A/B 都消失、两个删除调用和撤销不恢复已删除素材的断言。 - 关联:`src/hooks/useResolvedAssetReadUrl.ts`、`src/components/image-editor/ImageCanvasWorldView.tsx`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。 @@ -4219,6 +4221,7 @@ - 处理:从当前 root source 的 seed lane 动态解析全部零依赖首波任务,只对这些 child 容忍 hydration `Pending`,后续 code prototype / preview 仍严格要求 Running/Completed。`streaming / ready` 仍要求当前 revision,`committed` 回复改为依据 finalization 的稳定身份查询,不随后续项目 revision 失效。 - 验证:覆盖 `design-director / art-director / code-director` 三个 Pending 首波 child 均可投影 Completed、`code-prototype` Pending 仍被拒绝;非流式专业 Agent 在 finalization 前无 stream,提交后形成 committed stream,再推进项目 revision 后仍可查询且正文不变。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs`。 + ## 异步生成结果未知时不能换幂等键重提(2026-07-31) - 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。 @@ -4270,12 +4273,26 @@ - 处理:调用方在未认证时不得启动受保护的钱包刷新;可取消的读取要为每轮分配 `AbortController`,新读取先失效并中止旧读取,组件卸载时同时推进 revision、abort 当前请求并清空句柄。所有 `then / catch / finally` 在更新状态前都要检查 signal 与 revision。 - 验证:定向测试覆盖卸载后请求 signal 已中止;同时复跑触发钱包刷新回调的画布生成集成测试和完整前端测试,不能以单文件偶然快速收束代替全量验证。 +## 账号级轮询和并发 bootstrap 必须中止整条旧生命周期(2026-08-06) + +- 现象:任务列表 `Promise.all` 一侧失败后,另一侧请求可能跨过重试和卸载继续悬挂;微信充值第一次确认返回 pending 后切换账号,旧订单的延迟重试可能使用新账号 Token 再次请求,401 路径还会影响新账号登录态。 +- 原因:只用 React state 或最终回调里的 owner 判断,无法阻止已安排的 timer、下一次 HTTP 请求和同轮未完成分支继续执行;每轮重试覆盖单个 controller ref,也会遗失更早的悬挂请求。 +- 处理:并发 bootstrap 每次 attempt 使用独立 `AbortController`,任一分支失败时先中止同轮 controller 再安排有界重试,卸载时中止当前 attempt。充值订单从创建成功起持有同一个 owner、账号 revision 和 `AbortController`;每次 delay、confirm 和 SSE watch 前后都校验生命周期,并把同一 signal 传到请求层;账号切换和卸载先 abort,再清理 ref、state 与旧支付回调 hash。 +- 验证:bootstrap 用例覆盖“一侧 reject、另一侧 pending、重试后卸载”,并断言每轮 signal 都已中止;充值用 fake timer 证明首次确认 pending 后切换账号会中止 signal,推进全部退避时间也不会产生第二个确认请求或清理新账号 Token。 + +## 中止 refresh 等待不等于隔离 token 发布(2026-08-07) + +- 现象:A 账号的写请求 401 后开始共享 refresh,随后切换到 B。A 的 `AbortSignal` 虽然让业务请求立即结束且不再重放 POST,但底层 refresh 为了其它共享等待者不会被中止;A 的成功回包晚到时仍可能覆盖 B 的 token。 +- 原因:只对 `await` 叠加 abort 保护了调用链,没有给共享 Promise 的归属和最终 token 写入加账号栅栏;单一全局 Promise 还会让 B 加入 A 已在途的 refresh。 +- 处理:公开 token setter / clearer 每次都推进 auth generation;refresh 按 `generation + 发起时 access token` 共享、并以该快照 CAS 发布成功 token。快照已过期时成功回包转为失效结果,401/403 也不得清理新代际 token;新代际建立自己的 refresh Promise,旧 Promise 收尾时不得清掉新尝试。 +- 验证:`src/services/apiClient.test.ts` 要等旧 refresh 完整收束后断言 B token 不变,并用两个独立 deferred response 证明 B 会发起第二个 `/api/auth/refresh`;另覆盖旧 refresh 401 晚到不清 B token。 + ## 下游 manifest 回调测试不能冒充实时数据源(2026-08-05) - 现象:工作台的资源、任务与版本重投影单测保持绿色,但后台 Agent 已更新 `.agent/manifest.json` 后,打开中的工作台仍长期显示旧快照,只有重开项目才更新。 - 原因:测试 Supervisor 直接调用 `onManifestChange`,只证明 `App manifest -> WorkspaceLauncher -> ProjectDevelopmentView` 的下游桥接;真实 Runtime event 没有失效字段,监听器也没有重读 manifest。External Runner 又与 GUI 分属不同进程,Runner 内无法使用 GUI `AppHandle`,只补普通 Tauri event 仍不能形成生产链路。 - 处理:后台 manifest mutation 收敛到共用 Runtime emitter;GUI 内进程用带 `manifestInvalidated` 的 Runtime update,External Runner 通过 GUI owner attach 登记的受令牌保护 loopback sink 转发专用失效事件。App 对当前项目做 single-flight manifest 重读,并以 mounted、项目路径和 scope version 丢弃迟到结果;WorkspaceLauncher 继续只消费完整 manifest 快照,不新增平行状态或轮询。 -- 验证:集成测试必须渲染真实 `App + WorkspaceLauncher`、捕获真实 Tauri listener,让 `get_local_game_manifest` 从旧快照切换到新快照,并由非 Supervisor Agent 事件驱动资产、completed 任务、运行入口和版本卡出现;另测项目切换时旧请求迟到。旧的直接 `onManifestChange` 测试只能标记为下游桥接证据。 +- 验证:集成测试必须渲染真实 `App + WorkspaceLauncher`、捕获真实 Tauri listener,让 `get_local_game_manifest` 从旧快照切换到新快照,并由非 Supervisor Agent 事件驱动资产、completed 任务、运行入口和版本卡出现;另测项目切换时旧请求迟到。测试夹具必须先等待目标 Tauri listener 注册完成再发失效事件,并对“事件 -> manifest 重读 -> 工作台重投影”使用局部、有界的 `5_000ms` 等待,避免并行全量回归把监听注册或异步投影调度误判为功能失败。旧的直接 `onManifestChange` 测试只能标记为下游桥接证据。 ## React 资源详情焦点不能依赖重建对象身份(2026-08-05) @@ -4330,6 +4347,7 @@ - 现象:parent wake 的 200 次瞬态预算耗尽后 Runtime 仍长期显示 running,或 lane 忙、取消、child 前进、manifest 损坏时 reconciliation 被静默丢弃或覆盖新状态。 - 处理:预算耗尽错误必须向上传递;lane 忙先持久化 deferred signal,再在 lane + 项目锁内重检最新事实。结构损坏路径使用不依赖 manifest hydration 的专用 journal/state 写入,CAS 失败转为继续对账,绝不覆写并发取消或 DAG 进展。可解析的空对象/空 runId 仍是损坏身份,只有完整有效的新 Run 才能阻止旧 marker;event/audit 的同键记录必须完整比对并拒绝冲突或重复。旧 task 已终态、Runtime 非 waiting 或新 Run 接管时,deferred signal 必须写 resolved/superseded,不能留给后续 wake 永久重复 settle。 - 测试注意:autonomous child fixture 先 linked Pending、后正式 Running;终态 runId 必须拒绝复用。判断 Completed-only 诊断时按每个 seed task 的实际状态分析,不能因为 `code-prototype` Pending 就忽略已经 Completed 的 `art-asset-plan` 深验。 + ## macOS 安全路径测试必须使用规范化临时目录(2026-08-05) - 现象:调用仓库上下文、Runtime context bundle 或 pending recovery 的 Rust 测试在 macOS 报“Repository root and its ancestors must not be symbolic links”,Linux CI 却可能通过;本地 HTTP 恢复夹具在完整串行测试中还可能偶发 `WouldBlock`。 @@ -4358,6 +4376,7 @@ - 原因:把 dialog / canvas 的完整 UI 所有权同时用于账号级钱包和账号内项目级任务列表,或者任务列表只比较 project ID,没有校验账号。 - 处理:按副作用分层校验。钱包只比较账号;任务列表比较账号加项目;dialog、canvas、asset 和 layer 写回继续比较账号、项目、scope version 与原 dialog。正式请求已接受后,删除 UI 状态不等于取消后端任务。 - 验证:分别覆盖删除 dialog、同账号切项目、账号 A 切到账号 B 且 project ID 保持相同,以及原账号原项目原 dialog 仍有效的正常回写。 + ## GUI owner 锁不能替代逐 boot 的事件接收端登记(2026-08-05) - 现象:GUI 首次启动后 manifest 事件转发正常,但 Runner 被替换为新 boot 后只剩 owner 锁和 endpoint 可用,后台更新不再到达 GUI;或者 attach 响应只确认 owner,客户端却误记当前 boot 已完整登记,后续 ensure 不再重试。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index dd42aa144..3d098cf37 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -9,7 +9,7 @@ - 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。 - 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=`。 - 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。 -- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额优先展示画板按扣费、退回、到账和页面恢复链路刷新的 `profileDashboard.walletBalance`,充值中心 `mudPointBalance` 仅用于展开面板的账户明细,不覆盖已刷新的总余额。余额区只展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。 +- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额读取 owner 匹配的钱包 `mudPointBalance.totalPoints`;兼容响应只有 `walletBalance` 时读取共享钱包中 owner 隔离的 legacy 总额,不伪造不限时、每日免费或重置分桶。认证用户的钱包 owner 尚未绑定或 Store 仍为 `idle / loading` 时显示加载态,不能把未完成初始化渲染成余额不可用。余额区只在真实 `mudPointBalance` 存在时展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。 - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮二维平移、`Ctrl / Cmd + 滚轮` 缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index efe687615..e6a3aaf42 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -59,14 +59,14 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 ## 账户与充值 -1. 主站和图片画板统一使用公共泥点资产入口。收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及“每天重置为 20 泥点”,并提供“使用详情”入口;余额都以后端充值中心 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` 后才刷新余额或会员状态。 -8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `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`。前端下单入口还必须使用同步 operation token 防止同一 React 提交周期内重复创建订单;充值下单、邀请码兑换和奖励码兑换必须绑定同一个账号生命周期 `AbortSignal`,切号或卸载时先中止旧 signal,禁止 POST 的 401、503 或网络重试重新读取新账号 Token。共享 access token refresh 还必须按账号认证代际与发起时 token 快照隔离:旧代际成功回包不得发布 token,旧代际失败不得清理新账号 token,新账号不得复用旧账号的在途 refresh Promise。账号切换时,充值、账单、邀请码中心、邀请码输入、弹窗和在途读取结果都必须按账号生命周期整体失效。 9. 后台“充值商品”页继续维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。会员商品配置保留不表示当前版本开放公开购买或升级入口。 ## 唯一后端路线 diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 62e33f2cc..168790758 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -51,6 +51,7 @@ export type * from './contracts/visualNovel'; export * from './http'; export * from './llm/narrativeLanguage'; export * from './llm/parsers'; +export * from './stores/createProfileWalletStore'; export * from './utils/signedReadUrlCache'; // 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..c216c73dd --- /dev/null +++ b/packages/shared/src/stores/createProfileWalletStore.test.ts @@ -0,0 +1,337 @@ +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(); + const snapshot = store.getState().captureWalletBalanceSnapshot('user-a'); + expect(snapshot).not.toBeNull(); + store.getState().applyWalletBalanceSnapshot(snapshot!, 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'); + const snapshot = store.getState().captureWalletBalanceSnapshot('user-a'); + store.getState().applyWalletBalanceSnapshot(snapshot!, 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'); + const snapshot = store.getState().captureWalletBalanceSnapshot('user-a'); + store.getState().applyWalletBalanceSnapshot(snapshot!, balance(55)); + + await store.getState().onWalletBalanceMayHaveChanged(); + + expect(store.getState()).toMatchObject({ + mudPointBalance: balance(55), + mudPointBalanceStatus: 'error', + mudPointBalanceError: '泥点明细读取失败', + }); + }); + + 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('does not capture a snapshot for a different owner', () => { + const store = createProfileWalletStore({ + getRechargeCenter: vi.fn(), + }); + store.getState().setWalletOwner('user-b'); + + const snapshot = store.getState().captureWalletBalanceSnapshot('user-a'); + + expect(snapshot).toBeNull(); + expect(store.getState().mudPointBalance).toBeNull(); + expect(store.getState().mudPointBalanceStatus).toBe('idle'); + }); + + test('normalizes a snapshot owner before matching it', () => { + const store = createProfileWalletStore({ + getRechargeCenter: vi.fn(), + }); + store.getState().setWalletOwner('user-a'); + + const snapshot = store + .getState() + .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 an older direct snapshot after a newer operation has applied', () => { + const store = createProfileWalletStore({ getRechargeCenter: vi.fn() }); + store.getState().setWalletOwner('user-a'); + const olderSnapshot = store + .getState() + .captureWalletBalanceSnapshot('user-a'); + const newerSnapshot = store + .getState() + .captureWalletBalanceSnapshot('user-a'); + + expect( + store.getState().applyWalletBalanceSnapshot(newerSnapshot!, balance(100)), + ).toBe(true); + expect( + store.getState().applyWalletBalanceSnapshot(olderSnapshot!, balance(20)), + ).toBe(false); + expect(store.getState().mudPointBalance?.totalPoints).toBe(100); + }); + + test('retains a legacy total without inventing a balance breakdown', async () => { + const store = createProfileWalletStore({ + getRechargeCenter: vi.fn().mockResolvedValue({ + walletBalance: 37, + } as ProfileRechargeCenterResponse), + }); + store.getState().setWalletOwner('user-a'); + + await store.getState().onWalletBalanceMayHaveChanged(); + + expect(store.getState()).toMatchObject({ + legacyWalletBalance: 37, + mudPointBalance: null, + mudPointBalanceStatus: 'error', + mudPointBalanceError: '泥点明细读取失败', + }); + }); + + 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'); + 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 new file mode 100644 index 000000000..4cace6e85 --- /dev/null +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -0,0 +1,238 @@ +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 ProfileWalletBalanceSnapshot = Readonly<{ + ownerUserId: string; + ownerVersion: number; + invalidationVersion: number; + operationSequence: number; +}>; + +export type ProfileWalletStore = { + ownerUserId: string | null; + mudPointBalance: ProfileMudPointBalance | null; + legacyWalletBalance: number | null; + mudPointBalanceStatus: MudPointBalanceStatus; + mudPointBalanceError: string; + setWalletOwner: (userId: string | null) => void; + captureWalletBalanceSnapshot: ( + ownerUserId: string, + ) => ProfileWalletBalanceSnapshot | null; + applyWalletBalanceSnapshot: ( + snapshot: ProfileWalletBalanceSnapshot, + balance: ProfileMudPointBalance, + ) => boolean; + applyLegacyWalletBalanceSnapshot: ( + snapshot: ProfileWalletBalanceSnapshot, + balance: number, + ) => boolean; + onWalletBalanceMayHaveChanged: () => Promise; + resetWalletBalance: () => void; +}; + +const EMPTY_WALLET_STATE = { + mudPointBalance: null, + legacyWalletBalance: null, + mudPointBalanceStatus: 'idle', + mudPointBalanceError: '', +} as const; + +export function createProfileWalletStore( + api: ProfileWalletApi, +): UseBoundStore> { + let refreshVersion = 0; + let settledRefreshVersion = 0; + let ownerVersion = 0; + let operationSequence = 0; + let latestAppliedOperationSequence = 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; + } + + ownerVersion += 1; + latestAppliedOperationSequence = 0; + invalidateActiveRefresh(); + settledRefreshVersion = refreshVersion; + set({ + ownerUserId: normalizedUserId, + ...EMPTY_WALLET_STATE, + }); + }, + captureWalletBalanceSnapshot: (ownerUserId) => { + const normalizedOwnerUserId = ownerUserId.trim(); + if ( + !normalizedOwnerUserId || + get().ownerUserId !== normalizedOwnerUserId + ) { + return null; + } + + return { + ownerUserId: normalizedOwnerUserId, + ownerVersion, + invalidationVersion: refreshVersion, + operationSequence: ++operationSequence, + }; + }, + applyWalletBalanceSnapshot: (snapshot, balance) => { + if ( + 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: balance, + legacyWalletBalance: balance.totalPoints, + mudPointBalanceStatus: 'ready', + mudPointBalanceError: '', + }); + 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) { + 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) { + if (Number.isFinite(center.walletBalance)) { + set({ + mudPointBalance: null, + legacyWalletBalance: center.walletBalance, + }); + } + throw new Error('充值中心响应缺少泥点余额'); + } + + settledRefreshVersion = requestedVersion; + set({ + mudPointBalance: center.mudPointBalance, + legacyWalletBalance: center.mudPointBalance.totalPoints, + mudPointBalanceStatus: 'ready', + mudPointBalanceError: '', + }); + } catch { + if (generation !== requestGeneration) { + return; + } + if (requestedVersion !== refreshVersion) { + settledRefreshVersion = requestedVersion; + continue; + } + + settledRefreshVersion = requestedVersion; + set({ + mudPointBalanceStatus: 'error', + mudPointBalanceError: '泥点明细读取失败', + }); + } + } + })(); + activeRefresh = refresh; + activeAbortController = abortController; + const clearActiveRefresh = () => { + if (activeRefresh === refresh) { + activeRefresh = null; + } + if (activeAbortController === abortController) { + activeAbortController = null; + } + }; + void refresh.then(clearActiveRefresh, clearActiveRefresh); + return refresh; + }, + resetWalletBalance: () => { + ownerVersion += 1; + latestAppliedOperationSequence = 0; + invalidateActiveRefresh(); + settledRefreshVersion = refreshVersion; + set({ ownerUserId: null, ...EMPTY_WALLET_STATE }); + }, + })); +} diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 11cdae721..1c385e332 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -2,7 +2,7 @@ import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useEffect, useState } from 'react'; +import { StrictMode, useEffect, useState } from 'react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { AuthSessionSummary, AuthUser } from '../../services/authService'; @@ -16,6 +16,26 @@ import { useAuthUi } from './AuthUiContext'; const browserReloadMock = vi.hoisted(() => vi.fn()); +const walletLifecycleMocks = vi.hoisted(() => ({ + usePlatformWalletLifecycle: vi.fn(), +})); + +function createMemoryStorage(): Storage { + const values = new Map(); + return { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => Array.from(values.keys())[index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, String(value)), + }; +} + +const memoryLocalStorage = createMemoryStorage(); + const authMocks = vi.hoisted(() => ({ authEntry: vi.fn(), changePassword: vi.fn(), @@ -77,6 +97,8 @@ vi.mock('../../services/authService', () => ({ startWechatLogin: authMocks.startWechatLogin, })); +vi.mock('../../stores/usePlatformWalletStore', () => walletLifecycleMocks); + const hostBridgeMocks = vi.hoisted(() => ({ getHostRuntime: vi.fn(() => ({ kind: 'browser', @@ -136,6 +158,10 @@ const mockUser: AuthUser = { beforeEach(() => { vi.clearAllMocks(); + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: memoryLocalStorage, + }); window.localStorage.clear(); window.history.replaceState(null, '', '/'); setAuthGateReloadForTest(vi.fn()); @@ -381,6 +407,32 @@ test('auth gate keeps a valid local token login when refresh rotation fails afte expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); }); +test('auth root binds the single wallet lifecycle under StrictMode', async () => { + authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token'); + authMocks.refreshStoredAccessToken.mockRejectedValue( + new Error('refresh cookie 失效'), + ); + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: mockUser, + availableLoginMethods: ['phone'], + }); + + render( + + + + + , + ); + + expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy(); + await waitFor(() => { + expect( + walletLifecycleMocks.usePlatformWalletLifecycle, + ).toHaveBeenCalledWith('user-1', true); + }); +}); + test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => { authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: [], diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 80331e5a5..0af9b7027 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -50,6 +50,7 @@ import { reloadHostWebView, requestHostLogin, } from '../../services/host-bridge/hostBridge'; +import { usePlatformWalletLifecycle } from '../../stores/usePlatformWalletStore'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { AccountModal } from './AccountModal'; import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext'; @@ -117,10 +118,7 @@ function normalizeAvailableLoginMethods( // 登录面板的核心入口必须稳定展示,login-options 只补充微信等环境相关入口。 return Array.from( - new Set([ - ...REQUIRED_LOGIN_METHODS, - ...normalizedMethods, - ]), + new Set([...REQUIRED_LOGIN_METHODS, ...normalizedMethods]), ); } @@ -192,10 +190,7 @@ export function AuthGate({ children }: AuthGateProps) { } const markAuthStateReloadIfChanged = useCallback( - ( - nextUser: AuthUser | null, - options: { reloadOnChange?: boolean } = {}, - ) => { + (nextUser: AuthUser | null, options: { reloadOnChange?: boolean } = {}) => { const nextHasUser = Boolean(nextUser); const previousHasUser = lastStableAuthPresenceRef.current; if (previousHasUser === null) { @@ -204,23 +199,23 @@ export function AuthGate({ children }: AuthGateProps) { } lastStableAuthPresenceRef.current = nextHasUser; - if ( - previousHasUser !== nextHasUser && - options.reloadOnChange !== false - ) { + if (previousHasUser !== nextHasUser && options.reloadOnChange !== false) { pendingAuthStateReloadRef.current = true; } }, [], ); - const activateReadyUser = useCallback((nextUser: AuthUser) => { - // 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。 - authHydrateVersionRef.current += 1; - markAuthStateReloadIfChanged(nextUser); - setUser(nextUser); - setStatus('ready'); - }, [markAuthStateReloadIfChanged]); + const activateReadyUser = useCallback( + (nextUser: AuthUser) => { + // 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。 + authHydrateVersionRef.current += 1; + markAuthStateReloadIfChanged(nextUser); + setUser(nextUser); + setStatus('ready'); + }, + [markAuthStateReloadIfChanged], + ); const clearLocalAuthenticatedState = useCallback( (options: { reloadOnChange?: boolean } = {}) => { @@ -687,6 +682,11 @@ export function AuthGate({ children }: AuthGateProps) { ], ); + usePlatformWalletLifecycle( + readyUser?.id ?? null, + status === 'ready' && Boolean(readyUser), + ); + if (status === 'checking' && !canKeepPlatformContentMounted) { return (
{ 'creation-landing__asset-preview--campaign', ); expect(campaignPreview?.style.aspectRatio).toBe('900 / 1200'); - const campaignImage = await screen.findByRole('img', { - name: '活动精选', - }); + const campaignImage = await screen.findByRole( + 'img', + { name: '活动精选' }, + { timeout: 5_000 }, + ); expect((campaignImage as HTMLImageElement).src).toBe(signedCampaignUrl); expect(fetchMock).toHaveBeenCalledWith( `/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`, diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index fe1736a0d..1d4eef945 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -12,6 +12,7 @@ import userEvent from '@testing-library/user-event'; import JSZip from 'jszip'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation'; import { ApiClientError, @@ -294,6 +295,7 @@ describe('ImageCanvasEditorView', () => { }); beforeEach(() => { + usePlatformWalletStore.getState().resetWalletBalance(); loadFrontendRuntimeConfigMock.mockImplementation(() => immediateAsync({ imageEditorAgentSidebarEnabled: false, @@ -593,6 +595,21 @@ describe('ImageCanvasEditorView', () => { }); it('shows the live mud point balance in the canvas topbar when logged in', async () => { + usePlatformWalletStore.getState().setWalletOwner('user-1'); + 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( { ); 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 () => { @@ -658,8 +670,97 @@ describe('ImageCanvasEditorView', () => { expect(openAccountModal).toHaveBeenCalledTimes(1); }); + 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, + }); + render( + + + , + ); + + const walletButton = await screen.findByRole('button', { + name: '泥点 37', + }); + await user.hover(walletButton); + + const details = await screen.findByRole('dialog', { + name: '泥点账户详情', + }); + expect(within(details).getByText('泥点明细读取失败')).toBeTruthy(); + expect(within(details).queryByText('充值中心响应缺少泥点余额')).toBeNull(); + expect(within(details).queryByText('不限时泥点')).toBeNull(); + expect(within(details).queryByText('每日免费泥点')).toBeNull(); + }); + + it('keeps the wallet entry loading while the authenticated owner is not bound yet', async () => { + render( + + + , + ); + + 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'); + 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( void; }; @@ -319,12 +320,50 @@ const DEAD_INLINE_PLACEHOLDER_NOTICE = '上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。'; export function ImageCanvasEditorView({ + legacyWalletBalance = null, onProjectAccessLost, }: ImageCanvasEditorViewProps = {}) { const authUi = useAuthUi(); const [, setGenerationPricingVersion] = useState(0); - const [walletBalance, setWalletBalance] = useState(null); - const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false); + const walletOwnerUserId = usePlatformWalletStore( + (state) => state.ownerUserId, + ); + const storedMudPointBalance = usePlatformWalletStore( + (state) => state.mudPointBalance, + ); + const storedLegacyWalletBalance = usePlatformWalletStore( + (state) => state.legacyWalletBalance, + ); + 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 mudPointBalanceError = walletOwnerMatchesCurrentUser + ? storedMudPointBalanceError + : ''; + const walletBalance = + mudPointBalance?.totalPoints ?? + (walletOwnerMatchesCurrentUser + ? (storedLegacyWalletBalance ?? legacyWalletBalance) + : null); + const isWalletBalanceLoading = + Boolean(currentWalletOwnerUserId) && + (!walletOwnerMatchesCurrentUser || + storedMudPointBalanceStatus === 'idle' || + storedMudPointBalanceStatus === 'loading'); const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const assetListRef = useRef(null); @@ -507,21 +546,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, @@ -557,7 +581,6 @@ export function ImageCanvasEditorView({ activeTab: 'editor-canvas', isAuthenticated: Boolean(authUi?.user), showRechargeEntry, - onRechargeSuccess: refreshEditorWalletBalance, requestLogin: () => authUiRef.current?.openLoginModal(), currentUser: authUi?.user ?? null, }); @@ -565,9 +588,8 @@ export function ImageCanvasEditorView({ if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) { return; } - refreshEditorWalletBalance(); - loadRechargeCenter(); - }, [loadRechargeCenter, refreshEditorWalletBalance]); + void onWalletBalanceMayHaveChanged(); + }, [onWalletBalanceMayHaveChanged]); const isAccountPaymentModalOpen = isRewardCodeOpen || isRechargeOpen || @@ -589,66 +611,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, @@ -1081,8 +1043,10 @@ export function ImageCanvasEditorView({ layer.assetKind === 'character-animation' && (persistedAssetKind !== 'character-animation' || (asset.imageSequenceFrames?.length ?? 0) < 2 || - !(asset.imageSequenceDurationMs && - asset.imageSequenceDurationMs > 0)) + !( + asset.imageSequenceDurationMs && + asset.imageSequenceDurationMs > 0 + )) ) { throw new Error('服务器未返回完整的角色动作正式字段'); } @@ -1508,9 +1472,8 @@ export function ImageCanvasEditorView({ if (warning) { showGenerationWarning(warning); } - refreshEditorWalletState(); }, - [projectId, refreshEditorWalletState, showGenerationWarning], + [projectId, showGenerationWarning], ); const effectiveIsAgentConversationOpen = isAgentConversationEnabled && isAgentConversationOpen; @@ -2498,10 +2461,10 @@ export function ImageCanvasEditorView({ projectRenameError, layers, walletBalance, - walletBreakdown: rechargeCenter?.mudPointBalance ?? null, + walletBreakdown: mudPointBalance, isWalletBalanceLoading, isWalletDetailsLoading: isLoadingRechargeCenter, - walletDetailsError: rechargeError, + walletDetailsError: rechargeError || mudPointBalanceError || null, currentUser: authUi?.user, assetExportStatus, isExportingAssets, @@ -2609,6 +2572,7 @@ export function ImageCanvasEditorView({ onActivateGenerationDialog: activateCanvasGenerationDialog, onFocusExternalTask: focusExternalGenerationTask, onExternalTasksCompleted: handleExternalGenerationTasksCompleted, + onExternalTaskWalletMayHaveChanged: refreshEditorWalletState, onEditorAgentConfirmSent: handleEditorAgentConfirmSent, onToggleTaskSidebar: toggleTaskSidebar, onToggleAgentConversation: toggleAgentConversation, diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index eac4df1a2..544845781 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -141,6 +141,7 @@ export type ImageCanvasStageViewProps = { onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void; onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void; + onExternalTaskWalletMayHaveChanged?: () => void; onEditorAgentConfirmSent?: () => void; onToggleTaskSidebar: () => void; onToggleAgentConversation: () => void; @@ -284,6 +285,7 @@ export function ImageCanvasStageView({ onActivateGenerationDialog, onFocusExternalTask, onExternalTasksCompleted, + onExternalTaskWalletMayHaveChanged, onEditorAgentConfirmSent, onToggleTaskSidebar, onToggleAgentConversation, @@ -421,8 +423,7 @@ export function ImageCanvasStageView({ selectedLayer && perfectPixelLayerIds.has(selectedLayer.id), )} isPerfectPixelPendingConfirmation={Boolean( - selectedLayer && - pendingPerfectPixelLayerIds.has(selectedLayer.id), + selectedLayer && pendingPerfectPixelLayerIds.has(selectedLayer.id), )} onOpenQuickEditPanel={onOpenQuickEditPanel} onOpenRedrawPanel={onOpenRedrawPanel} @@ -529,6 +530,7 @@ export function ImageCanvasStageView({ onToggleOpen={onToggleTaskSidebar} onFocusExternalTask={onFocusExternalTask} onExternalTasksCompleted={onExternalTasksCompleted} + onExternalTaskWalletMayHaveChanged={onExternalTaskWalletMayHaveChanged} /> {isAgentConversationEnabled ? ( diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index 7808a5f4b..82747f3ec 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -474,6 +474,423 @@ describe('ImageCanvasTaskSidebarView', () => { } }); + it('refreshes the wallet while an external task is running and after a failed refund settles', async () => { + vi.useFakeTimers(); + try { + const onExternalTasksCompleted = vi.fn(); + const onExternalTaskWalletMayHaveChanged = vi.fn(); + let activeRequestCount = 0; + const runningTask = createExternalTask({ + jobId: 'wallet-running-task', + requestLabel: '长耗时图片生成', + status: 'running', + priceMudPoints: 20, + }); + const failedTask = createExternalTask({ + ...runningTask, + status: 'failed', + progress: 0, + phaseDetail: '生成失败。', + error: '生成失败,泥点已退回。', + completedAt: new Date().toISOString(), + }); + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => { + if (options.statuses?.includes('running')) { + activeRequestCount += 1; + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: activeRequestCount === 1 ? 1 : 0, + unacknowledgedTerminalCount: activeRequestCount === 1 ? 0 : 1, + updatedAtMicros: activeRequestCount, + }, + tasks: activeRequestCount === 1 ? [runningTask] : [], + }); + } + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: activeRequestCount > 1 ? 1 : 0, + updatedAtMicros: activeRequestCount, + }, + tasks: activeRequestCount > 1 ? [failedTask] : [], + }); + }, + ); + + render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + await Promise.resolve(); + }); + + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2); + expect(onExternalTasksCompleted).not.toHaveBeenCalled(); + expect(refreshCanvasMock).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps wallet polling global when the active external task belongs to another project', async () => { + vi.useFakeTimers(); + try { + const onExternalTaskWalletMayHaveChanged = vi.fn(); + let activeRequestCount = 0; + const otherProjectTask = createExternalTask({ + jobId: 'other-project-wallet-task', + sourceEntityId: 'project-other', + status: 'running', + }); + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => { + if (options.statuses?.includes('running')) { + activeRequestCount += 1; + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: activeRequestCount === 1 ? 1 : 0, + unacknowledgedTerminalCount: 0, + updatedAtMicros: activeRequestCount, + }, + tasks: activeRequestCount === 1 ? [otherProjectTask] : [], + }); + } + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: 0, + updatedAtMicros: activeRequestCount, + }, + tasks: [], + }); + }, + ); + + render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.queryByText('发光猫咪主视觉')).toBeNull(); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('retries a failed bootstrap and starts wallet polling from the recovered active task', async () => { + vi.useFakeTimers(); + try { + const onExternalTaskWalletMayHaveChanged = vi.fn(); + const runningTask = createExternalTask({ + jobId: 'bootstrap-recovered-task', + status: 'running', + }); + let activeRequestCount = 0; + listExternalGenerationTasksMock.mockImplementation( + ( + options: Parameters[0] = {}, + ): ReturnType => { + if (options.statuses?.includes('running')) { + activeRequestCount += 1; + if (activeRequestCount === 1) { + return Promise.reject(new Error('temporary bootstrap failure')); + } + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 1, + unacknowledgedTerminalCount: 0, + updatedAtMicros: activeRequestCount, + }, + tasks: [runningTask], + }); + } + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: 0, + updatedAtMicros: activeRequestCount, + }, + tasks: [], + }); + }, + ); + + render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2); + expect(activeRequestCount).toBe(3); + } finally { + vi.useRealTimers(); + } + }); + + it('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[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( + , + ); + + 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 { + listExternalGenerationTasksMock.mockRejectedValue( + new Error('task list unavailable'), + ); + + render( + , + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + await Promise.resolve(); + }); + + expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(8); + } finally { + vi.useRealTimers(); + } + }); + + it('clears stale tasks after a refreshed bootstrap exhausts its retries', async () => { + vi.useFakeTimers(); + try { + const staleTask = createExternalTask({ + jobId: 'stale-running-task', + requestLabel: '旧生成任务', + status: 'running', + }); + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => + Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: options.statuses?.includes('running') ? 1 : 0, + unacknowledgedTerminalCount: 0, + updatedAtMicros: 1, + }, + tasks: options.statuses?.includes('running') ? [staleTask] : [], + }), + ); + + const { rerender } = render( + , + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText('旧生成任务')).toBeTruthy(); + + listExternalGenerationTasksMock.mockRejectedValue( + new Error('task list unavailable'), + ); + rerender( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + await Promise.resolve(); + }); + + expect(screen.queryByText('旧生成任务')).toBeNull(); + expect(screen.getByText('暂无任务')).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); + + it('aborts every bootstrap attempt when one branch fails and after retry unmounts', async () => { + vi.useFakeTimers(); + try { + const requestSignals: AbortSignal[] = []; + let activeRequestCount = 0; + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => { + const signal = options.signal; + if (!signal) { + return Promise.reject(new Error('missing abort signal')); + } + requestSignals.push(signal); + if (options.statuses?.includes('running')) { + activeRequestCount += 1; + if (activeRequestCount === 1) { + return Promise.reject(new Error('active bootstrap failed')); + } + } + return new Promise< + Awaited> + >((_, reject) => { + signal.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ); + }); + }, + ); + + const { unmount } = render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(requestSignals).toHaveLength(2); + expect(requestSignals[0]).toBe(requestSignals[1]); + expect(requestSignals[0]!.aborted).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + await Promise.resolve(); + }); + expect(requestSignals).toHaveLength(4); + expect(requestSignals[2]).toBe(requestSignals[3]); + expect(requestSignals[2]!.aborted).toBe(false); + + unmount(); + expect(requestSignals[2]!.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it('loads more completed tasks while scrolling and keeps the request capped', async () => { const completedTasks = Array.from({ length: 120 }, (_, index) => { const completedAt = new Date(Date.now() - index * 1000).toISOString(); diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx index d987ce431..73ee61d4a 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -26,6 +26,7 @@ const COMPLETED_TASK_LIST_LIMIT = 20; const COMPLETED_TASK_LIST_MAX_LIMIT = 100; const ACTIVE_TASK_LIST_LIMIT = 100; const ACTIVE_TASK_POLL_INTERVAL_MS = 4000; +const TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS = [1000, 2000, 4000] as const; type TaskSidebarTab = 'active' | 'completed'; @@ -49,6 +50,7 @@ type ImageCanvasTaskSidebarViewProps = { onToggleOpen: () => void; onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void; + onExternalTaskWalletMayHaveChanged?: () => void; }; function parseTaskTimeMs(value?: string | null) { @@ -280,6 +282,7 @@ export function ImageCanvasTaskSidebarView({ onToggleOpen, onFocusExternalTask, onExternalTasksCompleted, + onExternalTaskWalletMayHaveChanged, }: ImageCanvasTaskSidebarViewProps) { const { refreshCanvas } = useImageCanvasActions(); const projectId = useImageCanvasContextStore((state) => state.projectId); @@ -289,6 +292,8 @@ export function ImageCanvasTaskSidebarView({ const [externalTasks, setExternalTasks] = useState< ExternalGenerationTaskRecord[] >([]); + const [walletActiveExternalTaskIds, setWalletActiveExternalTaskIds] = + useState([]); const [completedListState, setCompletedListState] = useState(() => ({ limit: COMPLETED_TASK_LIST_LIMIT, projectId: normalizedProjectId, @@ -353,54 +358,123 @@ export function ImageCanvasTaskSidebarView({ useEffect(() => { let disposed = false; - const controller = new AbortController(); - Promise.all([ - listExternalGenerationTasks({ + 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; + const activeTasksRequest = listExternalGenerationTasks({ limit: ACTIVE_TASK_LIST_LIMIT, includeAcknowledgedTerminal: false, statuses: ['running', 'queued'], - signal: controller.signal, - }), - listExternalGenerationTasks({ - limit: completedListLimit, - includeAcknowledgedTerminal: true, - statuses: ['completed', 'failed'], - signal: controller.signal, - }), - ]) - .then(([activeResponse, completedResponse]) => { - if (disposed) { + signal: attemptController.signal, + }).then((activeResponse) => { + if (disposed || attemptController.signal.aborted) { return; } - const visibleActiveTasks = filterVisibleExternalTasks( - activeResponse.tasks, - ); - const visibleCompletedTasks = filterVisibleExternalTasks( - completedResponse.tasks, - ); - notifyCompletedExternalTasks(visibleCompletedTasks, { - unacknowledgedOnly: true, - }); - setExternalTasks( + 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, visibleCompletedTasks), + mergeExternalTasks( + visibleActiveTasks, + currentTasks.filter(isTerminalExternalTask), + ), completedListLimit, ), ); - }) - .catch(() => { - if (!disposed) { - setExternalTasks([]); - } }); + 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) { + controller = null; + } + if (disposed) { + return; + } + if (retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length) { + 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]; + retryIndex += 1; + retryTimerId = window.setTimeout(loadTaskLists, retryDelayMs); + }) + .finally(() => { + if (controller === attemptController) { + controller = null; + } + }); + }; + + loadTaskLists(); return () => { disposed = true; - controller.abort(); + controller?.abort(); + if (retryTimerId !== null) { + window.clearTimeout(retryTimerId); + } }; }, [ completedListLimit, filterVisibleExternalTasks, notifyCompletedExternalTasks, + onExternalTaskWalletMayHaveChanged, refreshKey, ]); @@ -412,13 +486,14 @@ export function ImageCanvasTaskSidebarView({ [externalTasks], ); const activeExternalTaskKey = activeExternalTaskIds.join('|'); + const walletActiveExternalTaskKey = walletActiveExternalTaskIds.join('|'); useEffect(() => { activeExternalTaskIdsRef.current = new Set(activeExternalTaskIds); }, [activeExternalTaskKey, activeExternalTaskIds]); useEffect(() => { - if (!activeExternalTaskIds.length) { + if (!walletActiveExternalTaskKey) { return undefined; } let disposed = false; @@ -448,6 +523,15 @@ export function ImageCanvasTaskSidebarView({ filterVisibleExternalTasks(activeResponse.tasks), filterVisibleExternalTasks(completedResponse.tasks), ); + setWalletActiveExternalTaskIds( + activeResponse.tasks + .filter(isActiveExternalTask) + .map((task) => task.jobId), + ); + // external job 在 worker 领取后才预扣泥点,running 可能先于扣费落账。 + // 只要仍有全局 active task,每轮成功轮询都推动钱包收敛;终态这一轮 + // 同时覆盖成功结算或失败退款。共享 Store 会合并并发刷新。 + onExternalTaskWalletMayHaveChanged?.(); notifyCompletedExternalTasks(refreshedTasks, { activeTaskIds: activeExternalTaskIdsRef.current, }); @@ -483,11 +567,11 @@ export function ImageCanvasTaskSidebarView({ } }; }, [ - activeExternalTaskIds, - activeExternalTaskKey, completedListLimit, filterVisibleExternalTasks, notifyCompletedExternalTasks, + onExternalTaskWalletMayHaveChanged, + walletActiveExternalTaskKey, ]); const handleTaskListScroll = useCallback( diff --git a/src/components/platform-entry/PlatformActiveProfileView.test.tsx b/src/components/platform-entry/PlatformActiveProfileView.test.tsx index ad71ea2d2..080996f2d 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.test.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.test.tsx @@ -48,6 +48,9 @@ describe('PlatformActiveProfileView', () => { {...callbacks} dashboard={null} isLoadingDashboard={false} + isLoadingWalletBalance={false} + legacyWalletBalance={null} + mudPointBalance={null} user={null} />, ); @@ -68,6 +71,17 @@ describe('PlatformActiveProfileView', () => { updatedAt: '2026-07-18T00:00:00.000Z', }} isLoadingDashboard={false} + isLoadingWalletBalance={false} + legacyWalletBalance={null} + mudPointBalance={{ + totalPoints: 108, + permanentPoints: 88, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 20, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', + }} user={authenticatedUser} />, ); @@ -101,6 +115,9 @@ describe('PlatformActiveProfileView', () => { {...callbacks} dashboard={null} isLoadingDashboard={false} + isLoadingWalletBalance={false} + legacyWalletBalance={null} + mudPointBalance={null} user={authenticatedUser} />, ); @@ -133,6 +150,9 @@ describe('PlatformActiveProfileView', () => { {...callbacks} dashboard={null} isLoadingDashboard={false} + isLoadingWalletBalance={false} + legacyWalletBalance={null} + mudPointBalance={null} user={authenticatedUser} />, ); @@ -195,6 +215,9 @@ describe('PlatformActiveProfileView', () => { {...callbacks} dashboard={null} isLoadingDashboard={false} + isLoadingWalletBalance={false} + legacyWalletBalance={null} + mudPointBalance={null} user={authenticatedUser} />, ); diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx index 1c99cada2..8c5c39e74 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, + 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,9 @@ import { type PlatformActiveProfileViewProps = { dashboard: ProfileDashboardSummary | null; isLoadingDashboard: boolean; + isLoadingWalletBalance: boolean; + legacyWalletBalance: number | null; + mudPointBalance: ProfileMudPointBalance | null; onLogin: () => void; onOpenApiKeys: () => void; onOpenCommunity: () => void; @@ -237,6 +244,23 @@ function formatDashboardCount(value: number) { return Math.max(0, Math.round(value)).toLocaleString('zh-CN'); } +function formatWalletBalance( + balance: ProfileMudPointBalance | null, + legacyBalance: number | null, + isLoading: boolean, +) { + if (balance) { + return formatDashboardCount(balance.totalPoints); + } + if (legacyBalance !== null) { + return formatDashboardCount(legacyBalance); + } + if (isLoading) { + return '读取中'; + } + return '暂不可用'; +} + function formatTotalPlayTime(value: number) { const hours = Math.max(0, Math.round(value / 360000) / 10); return `${hours.toLocaleString('zh-CN', { @@ -247,6 +271,9 @@ function formatTotalPlayTime(value: number) { export function PlatformActiveProfileView({ dashboard, isLoadingDashboard, + isLoadingWalletBalance, + legacyWalletBalance, + mudPointBalance, onLogin, onOpenApiKeys, onOpenCommunity, @@ -539,11 +566,11 @@ export function PlatformActiveProfileView({ ({ value: { - user: null, + user: null as AuthUser | null, canAccessProtectedData: false, openLoginModal: vi.fn(), openAccountModal: vi.fn(), @@ -21,6 +33,16 @@ const responsiveMock = vi.hoisted(() => ({ isDesktopLayout: true, })); +const profileClientMock = vi.hoisted(() => ({ + getPlatformProfileDashboard: vi.fn(), + getPlatformProfileRechargeCenter: vi.fn(), +})); + +const profileCenterMock = vi.hoisted(() => ({ + isWalletLedgerOpen: false, + rechargeCenter: null as { walletBalance: number } | null, +})); + vi.mock('../auth/AuthUiContext', () => ({ useAuthUi: () => authUiMock.value, })); @@ -80,12 +102,22 @@ vi.mock('../project/ProjectGalleryView', () => ({ })); vi.mock('../image-editor/ImageCanvasEditorView', () => ({ - ImageCanvasEditorView: () =>
, + ImageCanvasEditorView: ({ + legacyWalletBalance, + }: { + legacyWalletBalance?: number | null; + }) => ( +
+ ), })); -vi.mock('../../services/platform-entry/platformProfileClient', () => ({ - getPlatformProfileDashboard: vi.fn(), -})); +vi.mock( + '../../services/platform-entry/platformProfileClient', + () => profileClientMock, +); vi.mock('./usePlatformProfileCenterController', () => ({ usePlatformProfileCenterController: () => ({ @@ -95,11 +127,11 @@ vi.mock('./usePlatformProfileCenterController', () => ({ isLoadingRechargeCenter: false, isLoadingWalletLedger: false, isRechargeOpen: false, - isWalletLedgerOpen: false, + isWalletLedgerOpen: profileCenterMock.isWalletLedgerOpen, loadRechargeCenter: vi.fn(), nativeWechatPayment: null, openWalletLedgerPanel: vi.fn(), - rechargeCenter: null, + rechargeCenter: profileCenterMock.rechargeCenter, rechargeError: null, rechargePaymentResult: null, setIsRechargeOpen: vi.fn(), @@ -112,6 +144,24 @@ vi.mock('./usePlatformProfileCenterController', () => ({ }), })); +function AuthWalletLifecycleTestBoundary({ + children, +}: { + children: ReactNode; +}) { + usePlatformWalletLifecycle( + authUiMock.value.user?.id ?? null, + authUiMock.value.canAccessProtectedData, + ); + return children; +} + +function render(ui: ReactElement) { + return testingLibraryRender(ui, { + wrapper: AuthWalletLifecycleTestBoundary, + }); +} + function StatefulPlatformEntryFlowShell({ initialStage, }: { @@ -130,10 +180,225 @@ function StatefulPlatformEntryFlowShell({ describe('PlatformEntryActiveFlowShell', () => { beforeEach(() => { window.history.replaceState(null, '', '/creation'); + usePlatformWalletStore.getState().resetWalletBalance(); + profileClientMock.getPlatformProfileDashboard.mockReset(); + profileClientMock.getPlatformProfileRechargeCenter.mockReset(); + profileCenterMock.isWalletLedgerOpen = false; + profileCenterMock.rechargeCenter = null; + 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('passes the lifecycle legacy total to profile and editor without opening recharge', 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({ + walletBalance: 37, + }); + profileCenterMock.isWalletLedgerOpen = true; + + const { rerender } = render( + , + ); + + expect( + await screen.findByRole('button', { name: '泥点余额 37' }), + ).toBeTruthy(); + expect(await screen.findByText('37泥点')).toBeTruthy(); + expect(screen.getByText('暂无账单记录')).toBeTruthy(); + + rerender( + , + ); + const imageEditor = await screen.findByRole('main', { + name: '图片画布编辑器', + }); + expect(imageEditor.getAttribute('data-legacy-wallet-balance')).toBe('37'); + }); + + 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('coalesces visibility and focus refreshes when the page returns to the foreground', 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 () => { + document.dispatchEvent(new Event('visibilitychange')); + 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( @@ -241,13 +506,9 @@ describe('PlatformEntryActiveFlowShell', () => { expect( await screen.findByRole('main', { name: '桌面端创作提示' }), ).toBeTruthy(); - expect( - screen.queryByRole('main', { name: '陶泥儿创作主页' }), - ).toBeNull(); + expect(screen.queryByRole('main', { name: '陶泥儿创作主页' })).toBeNull(); expect(screen.queryByRole('main', { name: '项目' })).toBeNull(); - expect( - screen.queryByRole('main', { name: '图片画布编辑器' }), - ).toBeNull(); + expect(screen.queryByRole('main', { name: '图片画布编辑器' })).toBeNull(); }, ); @@ -263,9 +524,7 @@ describe('PlatformEntryActiveFlowShell', () => { expect( await screen.findByRole('main', { name: '桌面端创作提示' }), ).toBeTruthy(); - expect( - screen.queryByRole('main', { name: '图片画布编辑器' }), - ).toBeNull(); + expect(screen.queryByRole('main', { name: '图片画布编辑器' })).toBeNull(); }); it('switches between creation and projects and passes search to the active page', async () => { diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 8cd069729..32310a11d 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -25,6 +25,7 @@ import { replaceAppHistoryPath, } from '../../routing/activeAppPageRoutes'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; import { useAuthUi } from '../auth/AuthUiContext'; import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel'; import { PlatformActionButton } from '../common/PlatformActionButton'; @@ -224,6 +225,37 @@ export function PlatformEntryFlowShellImpl({ const [isMobileDesktopGuideOpen, setIsMobileDesktopGuideOpen] = useState(false); const isDesktopLayout = usePlatformDesktopLayout(); + const currentWalletOwnerUserId = + authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null; + const walletOwnerUserId = usePlatformWalletStore( + (state) => state.ownerUserId, + ); + const storedMudPointBalance = usePlatformWalletStore( + (state) => state.mudPointBalance, + ); + const storedLegacyWalletBalance = usePlatformWalletStore( + (state) => state.legacyWalletBalance, + ); + const storedMudPointBalanceStatus = usePlatformWalletStore( + (state) => state.mudPointBalanceStatus, + ); + const storedMudPointBalanceError = usePlatformWalletStore( + (state) => state.mudPointBalanceError, + ); + const walletOwnerMatchesCurrentUser = + Boolean(currentWalletOwnerUserId) && + walletOwnerUserId === currentWalletOwnerUserId; + const mudPointBalance = walletOwnerMatchesCurrentUser + ? storedMudPointBalance + : null; + const mudPointBalanceError = walletOwnerMatchesCurrentUser + ? storedMudPointBalanceError + : ''; + const isWalletBalanceLoading = + Boolean(currentWalletOwnerUserId) && + (!walletOwnerMatchesCurrentUser || + storedMudPointBalanceStatus === 'idle' || + storedMudPointBalanceStatus === 'loading'); const refreshDashboard = useCallback(async () => { if (!authUi?.user || !authUi.canAccessProtectedData) { @@ -250,10 +282,14 @@ export function PlatformEntryFlowShellImpl({ activeTab: isProfileStage ? 'profile' : 'project', isAuthenticated: Boolean(authUi?.user), showRechargeEntry: true, - onRechargeSuccess: refreshDashboard, requestLogin: () => authUi?.openLoginModal(), currentUser: authUi?.user, }); + const legacyWalletBalance = walletOwnerMatchesCurrentUser + ? (storedLegacyWalletBalance ?? + profileCenter.rechargeCenter?.walletBalance ?? + null) + : null; const openCreation = useCallback(() => { if (!isDesktopLayout) { @@ -329,6 +365,7 @@ export function PlatformEntryFlowShellImpl({
}> @@ -337,11 +374,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 ?? legacyWalletBalance; const isCreationStage = !isProfileStage && (selectionStage === 'platform' || selectionStage === 'creation-home'); @@ -455,14 +488,9 @@ export function PlatformEntryFlowShellImpl({ authUi?.openLoginModal()} onOpenApiKeys={() => setIsApiKeysOpen(true)} diff --git a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx new file mode 100644 index 000000000..5b222cc95 --- /dev/null +++ b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx @@ -0,0 +1,370 @@ +/* @vitest-environment jsdom */ + +import { act, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { ProfileRechargeProduct } from '../../../packages/shared/src/contracts/runtime'; +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; +import { + apiClientMocks, + hostBridgeMocks, + paymentPlatformMocks, + profileClientMocks, + renderController, + userA, + userB, +} from './usePlatformProfileCenterController.testSupport'; + +const rechargeProduct = { + productId: 'points-60', + kind: 'points', +} as ProfileRechargeProduct; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function mudPointBalance(totalPoints: number) { + return { + totalPoints, + permanentPoints: totalPoints, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-07T00:00:00+08:00', + }; +} + +function nativeRechargeResponse(totalPoints = 80, orderId = 'order-a') { + return { + order: { + orderId, + productTitle: '60 泥点', + amountCents: 600, + status: 'pending', + expirationCheckedAt: null, + }, + center: { + walletBalance: totalPoints, + mudPointBalance: mudPointBalance(totalPoints), + }, + wechatNativePayment: { + codeUrl: `weixin://wxpay/${orderId}`, + expiresAt: '2099-08-07T00:00:00+08:00', + }, + }; +} + +describe('usePlatformProfileCenterController recharge fallback', () => { + beforeEach(() => { + window.history.replaceState(null, '', '/profile'); + usePlatformWalletStore.getState().resetWalletBalance(); + vi.clearAllMocks(); + paymentPlatformMocks.resolveProfileRechargeProductPaymentChannel.mockReturnValue( + 'wechat_native', + ); + hostBridgeMocks.getHostRuntime.mockReturnValue({ kind: 'browser' }); + profileClientMocks.watchWechatPlatformProfileRechargeOrder.mockReturnValue( + new Promise(() => undefined), + ); + }); + + test('keeps the response legacy balance when no shared snapshot exists', async () => { + usePlatformWalletStore.getState().setWalletOwner('user-a'); + profileClientMocks.getPlatformProfileRechargeCenter.mockResolvedValue({ + walletBalance: 37, + membership: null, + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }); + const { result } = renderController(userA); + + act(() => result.current.loadRechargeCenter()); + + await waitFor(() => { + expect(result.current.rechargeCenter?.walletBalance).toBe(37); + }); + }); + + test('synchronously rejects a duplicate recharge submission before React commits state', async () => { + const pendingOrder = deferred>(); + 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['order']; + center: ReturnType['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>(); + profileClientMocks.createPlatformProfileRechargeOrder + .mockReturnValueOnce(pendingOrder.promise) + .mockResolvedValueOnce(nativeRechargeResponse(90, 'order-b')); + usePlatformWalletStore.getState().setWalletOwner('user-a'); + const { result, rerender } = renderController(userA); + + act(() => result.current.buyRechargeProduct(rechargeProduct)); + const oldAccountSignal = profileClientMocks + .createPlatformProfileRechargeOrder.mock.calls[0]?.[2] + ?.signal as AbortSignal; + expect(oldAccountSignal.aborted).toBe(false); + expect(result.current.submittingRechargeProductId).toBe('points-60'); + + act(() => { + rerender({ user: userB }); + usePlatformWalletStore.getState().setWalletOwner('user-b'); + }); + expect(oldAccountSignal.aborted).toBe(true); + act(() => result.current.buyRechargeProduct(rechargeProduct)); + await waitFor(() => { + expect(result.current.nativeWechatPayment?.orderId).toBe('order-b'); + }); + await act(async () => { + pendingOrder.resolve(nativeRechargeResponse()); + await pendingOrder.promise; + await Promise.resolve(); + }); + + expect(result.current.submittingRechargeProductId).toBeNull(); + expect(result.current.nativeWechatPayment?.orderId).toBe('order-b'); + expect(result.current.rechargePaymentResult).toBeNull(); + expect(result.current.rechargeCenter?.walletBalance).toBe(90); + expect(usePlatformWalletStore.getState()).toMatchObject({ + ownerUserId: 'user-b', + mudPointBalance: mudPointBalance(90), + }); + }); + + test('aborts an in-flight recharge write when the controller unmounts', () => { + profileClientMocks.createPlatformProfileRechargeOrder.mockReturnValue( + new Promise(() => undefined), + ); + usePlatformWalletStore.getState().setWalletOwner('user-a'); + const { result, unmount } = renderController(userA); + + act(() => result.current.buyRechargeProduct(rechargeProduct)); + const signal = profileClientMocks.createPlatformProfileRechargeOrder.mock + .calls[0]?.[2]?.signal as AbortSignal; + expect(signal.aborted).toBe(false); + + unmount(); + + expect(signal.aborted).toBe(true); + }); + + test('ignores an old owner native confirmation after the account changes', async () => { + const pendingConfirmation = deferred<{ + order: ReturnType['order']; + center: ReturnType['center']; + }>(); + const onRechargeSuccess = vi.fn(); + profileClientMocks.createPlatformProfileRechargeOrder.mockResolvedValue( + nativeRechargeResponse(), + ); + profileClientMocks.confirmWechatPlatformProfileRechargeOrder.mockReturnValue( + pendingConfirmation.promise, + ); + usePlatformWalletStore.getState().setWalletOwner('user-a'); + const { result, rerender } = renderController(userA, onRechargeSuccess); + + act(() => result.current.buyRechargeProduct(rechargeProduct)); + await waitFor(() => { + expect(result.current.nativeWechatPayment?.orderId).toBe('order-a'); + }); + act(() => result.current.confirmNativeWechatPayment()); + + act(() => { + rerender({ user: userB }); + usePlatformWalletStore.getState().setWalletOwner('user-b'); + }); + await act(async () => { + pendingConfirmation.resolve({ + order: { + ...nativeRechargeResponse().order, + status: 'paid', + }, + center: nativeRechargeResponse(100).center, + }); + await pendingConfirmation.promise; + await Promise.resolve(); + }); + + expect(result.current.nativeWechatPayment).toBeNull(); + expect(result.current.rechargePaymentResult).toBeNull(); + expect(result.current.wechatRechargeOrderConfirmationState).toBeNull(); + expect(onRechargeSuccess).not.toHaveBeenCalled(); + expect(usePlatformWalletStore.getState()).toMatchObject({ + ownerUserId: 'user-b', + mudPointBalance: null, + }); + }); + + test('aborts delayed confirmation retries when the account changes', async () => { + vi.useFakeTimers(); + try { + profileClientMocks.createPlatformProfileRechargeOrder.mockResolvedValue( + nativeRechargeResponse(), + ); + profileClientMocks.confirmWechatPlatformProfileRechargeOrder.mockResolvedValue( + { + order: nativeRechargeResponse().order, + center: nativeRechargeResponse().center, + }, + ); + usePlatformWalletStore.getState().setWalletOwner('user-a'); + const { result, rerender } = renderController(userA); + + await act(async () => { + result.current.buyRechargeProduct(rechargeProduct); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(result.current.nativeWechatPayment?.orderId).toBe('order-a'); + + await act(async () => { + result.current.confirmNativeWechatPayment(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect( + profileClientMocks.confirmWechatPlatformProfileRechargeOrder, + ).toHaveBeenCalledTimes(1); + const confirmationSignal = profileClientMocks + .confirmWechatPlatformProfileRechargeOrder.mock.calls[0]?.[1] + ?.signal as AbortSignal; + expect(confirmationSignal.aborted).toBe(false); + expect( + profileClientMocks.watchWechatPlatformProfileRechargeOrder, + ).toHaveBeenCalledWith('order-a', { signal: confirmationSignal }); + + act(() => { + rerender({ user: userB }); + usePlatformWalletStore.getState().setWalletOwner('user-b'); + }); + expect(confirmationSignal.aborted).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect( + profileClientMocks.confirmWechatPlatformProfileRechargeOrder, + ).toHaveBeenCalledTimes(1); + expect(apiClientMocks.clearStoredAccessToken).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + test('does not clear the new owner token from an old owner payment error', async () => { + const pendingOrder = deferred(); + paymentPlatformMocks.resolveProfileRechargeProductPaymentChannel.mockReturnValue( + 'wechat_mp_virtual', + ); + hostBridgeMocks.getHostRuntime.mockReturnValue({ + kind: 'wechat_mini_program', + }); + profileClientMocks.createPlatformProfileRechargeOrder + .mockReturnValueOnce(pendingOrder.promise) + .mockResolvedValueOnce(nativeRechargeResponse(70, 'order-b')); + usePlatformWalletStore.getState().setWalletOwner('user-a'); + const { result, rerender } = renderController(userA); + + act(() => result.current.buyRechargeProduct(rechargeProduct)); + act(() => { + rerender({ user: userB }); + usePlatformWalletStore.getState().setWalletOwner('user-b'); + }); + paymentPlatformMocks.resolveProfileRechargeProductPaymentChannel.mockReturnValue( + 'wechat_native', + ); + act(() => result.current.buyRechargeProduct(rechargeProduct)); + await waitFor(() => { + expect(result.current.nativeWechatPayment?.orderId).toBe('order-b'); + }); + await act(async () => { + pendingOrder.reject(new Error('当前登录设备不支持充值')); + await pendingOrder.promise.catch(() => undefined); + await Promise.resolve(); + }); + + expect(apiClientMocks.clearStoredAccessToken).not.toHaveBeenCalled(); + expect(hostBridgeMocks.requestHostLogin).not.toHaveBeenCalled(); + expect(result.current.rechargeError).toBeNull(); + expect(result.current.rechargePaymentResult).toBeNull(); + expect(result.current.nativeWechatPayment?.orderId).toBe('order-b'); + expect(usePlatformWalletStore.getState()).toMatchObject({ + ownerUserId: 'user-b', + mudPointBalance: mudPointBalance(70), + }); + }); +}); diff --git a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx new file mode 100644 index 000000000..dbbb10029 --- /dev/null +++ b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx @@ -0,0 +1,128 @@ +/* @vitest-environment jsdom */ + +import { act } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; +import { + profileClientMocks, + renderController, + userA, + userB, +} from './usePlatformProfileCenterController.testSupport'; + +describe('usePlatformProfileCenterController redemption lifecycle', () => { + beforeEach(() => { + window.history.replaceState(null, '', '/profile'); + usePlatformWalletStore.getState().resetWalletBalance(); + vi.clearAllMocks(); + }); + + test('ignores a reward-code response after the account changes', async () => { + let resolveReward!: (value: { + walletBalance: number; + amountGranted: number; + ledgerEntry: never; + }) => void; + profileClientMocks.redeemPlatformProfileRewardCode.mockImplementation( + () => + new Promise((resolve) => { + resolveReward = resolve; + }), + ); + const onRechargeSuccess = vi.fn(); + const { result, rerender } = renderController(userA, onRechargeSuccess); + act(() => result.current.setRewardCodeInput('reward-code')); + act(() => result.current.submitRewardCode()); + const oldAccountSignal = profileClientMocks.redeemPlatformProfileRewardCode + .mock.calls[0]?.[1]?.signal as AbortSignal; + expect(oldAccountSignal.aborted).toBe(false); + + rerender({ user: userB }); + expect(oldAccountSignal.aborted).toBe(true); + await act(async () => { + resolveReward({ + walletBalance: 100, + amountGranted: 20, + ledgerEntry: undefined as never, + }); + }); + + expect(result.current.rewardCodeSuccess).toBeNull(); + expect(result.current.isSubmittingRewardCode).toBe(false); + expect(onRechargeSuccess).not.toHaveBeenCalled(); + }); + + test('ignores an invite-code response after the account changes', async () => { + let resolveReferral!: (value: { + center: never; + inviteeRewardGranted: boolean; + inviterRewardGranted: boolean; + inviteeBalanceAfter: number; + inviterBalanceAfter: number; + }) => void; + profileClientMocks.redeemPlatformProfileReferralInviteCode.mockImplementation( + () => + new Promise((resolve) => { + resolveReferral = resolve; + }), + ); + const onRechargeSuccess = vi.fn(); + const { result, rerender } = renderController(userA, onRechargeSuccess); + act(() => result.current.setReferralRedeemCode('invite-code')); + act(() => result.current.submitReferralRedeemCode()); + const oldAccountSignal = profileClientMocks + .redeemPlatformProfileReferralInviteCode.mock.calls[0]?.[1] + ?.signal as AbortSignal; + expect(oldAccountSignal.aborted).toBe(false); + + rerender({ user: userB }); + expect(oldAccountSignal.aborted).toBe(true); + await act(async () => { + resolveReferral({ + center: undefined as never, + inviteeRewardGranted: true, + inviterRewardGranted: false, + inviteeBalanceAfter: 100, + inviterBalanceAfter: 0, + }); + }); + + expect(result.current.referralSuccess).toBeNull(); + 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); + }); +}); diff --git a/src/components/platform-entry/usePlatformProfileCenterController.testSupport.tsx b/src/components/platform-entry/usePlatformProfileCenterController.testSupport.tsx new file mode 100644 index 000000000..118fac437 --- /dev/null +++ b/src/components/platform-entry/usePlatformProfileCenterController.testSupport.tsx @@ -0,0 +1,102 @@ +/* @vitest-environment jsdom */ + +import { renderHook } from '@testing-library/react'; +import { vi } from 'vitest'; + +import type { AuthUser } from '../../services/authService'; + +const profileClientMocks = vi.hoisted(() => ({ + confirmWechatPlatformProfileRechargeOrder: vi.fn(), + createPlatformProfileRechargeOrder: vi.fn(), + getPlatformProfileRechargeCenter: vi.fn(), + getPlatformProfileReferralInviteCenter: vi.fn(), + getPlatformProfileWalletLedger: vi.fn(), + redeemPlatformProfileReferralInviteCode: vi.fn(), + redeemPlatformProfileRewardCode: vi.fn(), + watchWechatPlatformProfileRechargeOrder: vi.fn(), +})); + +const apiClientMocks = vi.hoisted(() => ({ + clearStoredAccessToken: vi.fn(), +})); + +const hostBridgeMocks = vi.hoisted(() => ({ + getHostRuntime: vi.fn(() => ({ kind: 'browser' })), + requestHostLogin: vi.fn(), + requestHostPayment: vi.fn(), +})); + +const paymentPlatformMocks = vi.hoisted(() => ({ + resolveProfileRechargeProductPaymentChannel: vi.fn(() => 'wechat_native'), +})); + +vi.mock( + '../../services/platform-entry/platformProfileClient', + () => profileClientMocks, +); + +vi.mock('../../services/apiClient', async () => { + const actual = await vi.importActual< + typeof import('../../services/apiClient') + >('../../services/apiClient'); + return { ...actual, ...apiClientMocks }; +}); + +vi.mock('../../services/host-bridge/hostBridge', async () => { + const actual = await vi.importActual< + typeof import('../../services/host-bridge/hostBridge') + >('../../services/host-bridge/hostBridge'); + return { ...actual, ...hostBridgeMocks }; +}); + +vi.mock('../../services/payment/paymentPlatform', async () => { + const actual = await vi.importActual< + typeof import('../../services/payment/paymentPlatform') + >('../../services/payment/paymentPlatform'); + return { ...actual, ...paymentPlatformMocks }; +}); + +export { + apiClientMocks, + hostBridgeMocks, + paymentPlatformMocks, + profileClientMocks, +}; + +import { usePlatformProfileCenterController } from './usePlatformProfileCenterController'; + +const baseUser = { + publicUserCode: '100001', + displayName: '测试用户', + avatarUrl: null, + phoneNumberMasked: null, + loginMethod: 'password', + bindingStatus: 'active', + wechatBound: false, +} satisfies Omit; + +export const userA = { ...baseUser, id: 'user-a' } satisfies AuthUser; +export const userB = { + ...baseUser, + id: 'user-b', + publicUserCode: '100002', +} satisfies AuthUser; + +export function renderController( + currentUser: AuthUser, + onRechargeSuccess = vi.fn(), +) { + const requestLogin = vi.fn(); + return renderHook( + ({ user }) => + usePlatformProfileCenterController({ + activeTab: 'settings', + isAuthenticated: true, + showRechargeEntry: true, + onRechargeSuccess, + requestLogin, + currentUser: user, + }), + { initialProps: { user: currentUser } }, + ); +} diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index a3a0c966e..03a5f423d 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, @@ -79,6 +80,22 @@ export type WechatRechargeOrderConfirmationState = { export type NativeWechatPaymentState = PlatformProfileRechargeNativePaymentState; +type AccountLifecycle = Readonly<{ + ownerUserId: string; + revision: number; +}>; + +type WechatRechargeOrderLifecycle = Readonly<{ + orderId: string; + account: AccountLifecycle; + abortController: AbortController; +}>; + +type WechatRechargeConfirmationOptions = Readonly<{ + signal: AbortSignal; + isCurrent: () => boolean; +}>; + function isWechatJsapiMissingIdentityError(error: unknown) { return ( error instanceof Error && @@ -175,9 +192,36 @@ function readWechatPayResultFromHash(): WechatPayResult | null { }; } -function waitWechatPayConfirmDelay(delayMs: number) { - return new Promise((resolve) => { - window.setTimeout(resolve, delayMs); +function createWechatRechargeAbortError(signal: AbortSignal) { + return signal.reason instanceof Error + ? signal.reason + : new DOMException('Wechat recharge confirmation aborted', 'AbortError'); +} + +function assertWechatRechargeConfirmationActive( + options: WechatRechargeConfirmationOptions, +) { + if (options.signal.aborted || !options.isCurrent()) { + throw createWechatRechargeAbortError(options.signal); + } +} + +function waitWechatPayConfirmDelay(delayMs: number, signal: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(createWechatRechargeAbortError(signal)); + return; + } + const timerId = window.setTimeout(() => { + signal.removeEventListener('abort', handleAbort); + resolve(); + }, delayMs); + const handleAbort = () => { + window.clearTimeout(timerId); + signal.removeEventListener('abort', handleAbort); + reject(createWechatRechargeAbortError(signal)); + }; + signal.addEventListener('abort', handleAbort, { once: true }); }); } @@ -197,42 +241,71 @@ function isWechatRechargeOrderTerminalForConfirmation( async function confirmWechatRechargeOrderUntilSettled( orderId: string, + options: WechatRechargeConfirmationOptions, ): Promise { - let latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId); + assertWechatRechargeConfirmationActive(options); + let latestResponse = await confirmWechatPlatformProfileRechargeOrder( + orderId, + { signal: options.signal }, + ); + assertWechatRechargeConfirmationActive(options); if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } for (const delayMs of WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS) { - await waitWechatPayConfirmDelay(delayMs); + assertWechatRechargeConfirmationActive(options); + await waitWechatPayConfirmDelay(delayMs, options.signal); + assertWechatRechargeConfirmationActive(options); - latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId); + latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId, { + signal: options.signal, + }); + assertWechatRechargeConfirmationActive(options); if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } } try { - const streamedResponse = - await watchWechatPlatformProfileRechargeOrder(orderId); + assertWechatRechargeConfirmationActive(options); + const streamedResponse = await watchWechatPlatformProfileRechargeOrder( + orderId, + { + signal: options.signal, + }, + ); + assertWechatRechargeConfirmationActive(options); return streamedResponse; } catch { + assertWechatRechargeConfirmationActive(options); return latestResponse; } } async function confirmWechatRechargeOrderQuickly( orderId: string, + options: WechatRechargeConfirmationOptions, ): Promise { - let latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId); + assertWechatRechargeConfirmationActive(options); + let latestResponse = await confirmWechatPlatformProfileRechargeOrder( + orderId, + { signal: options.signal }, + ); + assertWechatRechargeConfirmationActive(options); if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } for (const delayMs of WECHAT_NATIVE_CONFIRM_RETRY_DELAYS_MS) { - await waitWechatPayConfirmDelay(delayMs); + assertWechatRechargeConfirmationActive(options); + await waitWechatPayConfirmDelay(delayMs, options.signal); + assertWechatRechargeConfirmationActive(options); - latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId); + latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId, { + signal: options.signal, + }); + assertWechatRechargeConfirmationActive(options); if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } @@ -351,19 +424,175 @@ export function usePlatformProfileCenterController({ const [referralSuccess, setReferralSuccess] = useState(null); const { copyState: inviteCopyState, copyText: copyInviteText } = useCopyFeedback(); - const pendingWechatRechargeOrderIdRef = useRef(null); - const confirmingWechatRechargeOrderIdRef = useRef(null); + const pendingWechatRechargeOrderRef = + useRef(null); + const confirmingWechatRechargeOrderRef = + useRef(null); + const rechargeSubmissionRef = useRef(null); + const accountWriteAbortControllerRef = useRef(new AbortController()); const rechargeCenterReadRevisionRef = useRef(0); - const rechargeCenterReadAbortControllerRef = - useRef(null); + const accountLifecycleRevisionRef = useRef(0); + const walletLedgerReadRevisionRef = useRef(0); + const referralCenterReadRevisionRef = useRef(0); + const currentUserId = currentUser?.id ?? ''; + const currentUserIdRef = useRef(currentUserId); + currentUserIdRef.current = currentUserId; + const captureAccountLifecycle = useCallback( + (): AccountLifecycle => ({ + ownerUserId: currentUserId, + revision: accountLifecycleRevisionRef.current, + }), + [currentUserId], + ); + const isAccountLifecycleCurrent = useCallback( + (account: AccountLifecycle) => + account.revision === accountLifecycleRevisionRef.current && + account.ownerUserId === currentUserIdRef.current, + [], + ); + const isRechargeOrderCurrent = useCallback( + ( + order: WechatRechargeOrderLifecycle | null | undefined, + ): order is WechatRechargeOrderLifecycle => + Boolean(order) && isAccountLifecycleCurrent(order!.account), + [isAccountLifecycleCurrent], + ); + const isSameRechargeOrder = useCallback( + ( + left: WechatRechargeOrderLifecycle | null | undefined, + right: WechatRechargeOrderLifecycle | null | undefined, + ) => { + if (!left || !right) { + return false; + } + return ( + left.orderId === right.orderId && + left.account.ownerUserId === right.account.ownerUserId && + left.account.revision === right.account.revision + ); + }, + [], + ); + const abortRechargeOrders = useCallback( + (...orders: Array) => { + const controllers = new Set( + orders.flatMap((order) => (order ? [order.abortController] : [])), + ); + controllers.forEach((controller) => controller.abort()); + }, + [], + ); + const createRechargeOrderLifecycle = useCallback( + ( + orderId: string, + account: AccountLifecycle, + ): WechatRechargeOrderLifecycle => ({ + orderId, + account, + abortController: new AbortController(), + }), + [], + ); + const createRechargeConfirmationOptions = useCallback( + ( + order: WechatRechargeOrderLifecycle, + ): WechatRechargeConfirmationOptions => ({ + signal: order.abortController.signal, + isCurrent: () => + isRechargeOrderCurrent(order) && + (isSameRechargeOrder(pendingWechatRechargeOrderRef.current, order) || + isSameRechargeOrder(confirmingWechatRechargeOrderRef.current, order)), + }), + [isRechargeOrderCurrent, isSameRechargeOrder], + ); + const walletOwnerUserId = usePlatformWalletStore( + (state) => state.ownerUserId, + ); + const storedMudPointBalance = usePlatformWalletStore( + (state) => state.mudPointBalance, + ); + const applyWalletBalanceSnapshot = usePlatformWalletStore( + (state) => state.applyWalletBalanceSnapshot, + ); + const captureWalletBalanceSnapshot = usePlatformWalletStore( + (state) => state.captureWalletBalanceSnapshot, + ); + const onWalletBalanceMayHaveChanged = usePlatformWalletStore( + (state) => state.onWalletBalanceMayHaveChanged, + ); + const walletOwnerMatchesCurrentUser = + Boolean(currentUserId) && walletOwnerUserId === currentUserId; + const mudPointBalance = walletOwnerMatchesCurrentUser + ? storedMudPointBalance + : null; + + useEffect(() => { + accountWriteAbortControllerRef.current.abort(); + const accountWriteAbortController = new AbortController(); + accountWriteAbortControllerRef.current = accountWriteAbortController; + rechargeCenterReadRevisionRef.current += 1; + accountLifecycleRevisionRef.current += 1; + walletLedgerReadRevisionRef.current += 1; + referralCenterReadRevisionRef.current += 1; + const pendingOrder = pendingWechatRechargeOrderRef.current; + const confirmingOrder = confirmingWechatRechargeOrderRef.current; + abortRechargeOrders(pendingOrder, confirmingOrder); + if ( + (pendingOrder && pendingOrder.account.ownerUserId !== currentUserId) || + (confirmingOrder && confirmingOrder.account.ownerUserId !== currentUserId) + ) { + clearWechatPayResultHash(); + } + pendingWechatRechargeOrderRef.current = null; + confirmingWechatRechargeOrderRef.current = null; + rechargeSubmissionRef.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(''); + setIsSubmittingRewardCode(false); + setRewardCodeError(null); + setRewardCodeSuccess(null); + setProfilePopupPanel(null); + setReferralCenter(null); + setIsLoadingReferral(false); + setIsReferralCenterInitialized(false); + setReferralRedeemCode(''); + setIsSubmittingReferralRedeem(false); + setReferralError(null); + setReferralSuccess(null); + return () => { + accountWriteAbortController.abort(); + }; + }, [abortRechargeOrders, currentUserId]); + const rechargeCenterReadAbortControllerRef = useRef( + null, + ); useEffect(() => { return () => { rechargeCenterReadRevisionRef.current += 1; + accountLifecycleRevisionRef.current += 1; + referralCenterReadRevisionRef.current += 1; + abortRechargeOrders( + pendingWechatRechargeOrderRef.current, + confirmingWechatRechargeOrderRef.current, + ); + pendingWechatRechargeOrderRef.current = null; + confirmingWechatRechargeOrderRef.current = null; rechargeCenterReadAbortControllerRef.current?.abort(); rechargeCenterReadAbortControllerRef.current = null; }; - }, []); + }, [abortRechargeOrders]); // 中文注释:支持带邀请码 query 的直达场景,登录成功后自动打开兑换面板并复用同一套输入状态。 useEffect(() => { @@ -387,18 +616,44 @@ export function usePlatformProfileCenterController({ }, [currentUser, pendingProfileInviteCode, requestLogin]); const loadWalletLedger = useCallback(() => { + const snapshotOwnerUserId = currentUserId; + const accountRevision = accountLifecycleRevisionRef.current; + const revision = ++walletLedgerReadRevisionRef.current; setWalletLedgerError(null); setIsLoadingWalletLedger(true); void getPlatformProfileWalletLedger() - .then(setWalletLedger) + .then((ledger) => { + if ( + revision === walletLedgerReadRevisionRef.current && + accountRevision === accountLifecycleRevisionRef.current && + currentUserIdRef.current === snapshotOwnerUserId + ) { + setWalletLedger(ledger); + } + }) .catch((error: unknown) => { + if ( + revision !== walletLedgerReadRevisionRef.current || + accountRevision !== accountLifecycleRevisionRef.current || + currentUserIdRef.current !== snapshotOwnerUserId + ) { + return; + } setWalletLedger(null); setWalletLedgerError( error instanceof Error ? error.message : '读取泥点账单失败', ); }) - .finally(() => setIsLoadingWalletLedger(false)); - }, []); + .finally(() => { + if ( + revision === walletLedgerReadRevisionRef.current && + accountRevision === accountLifecycleRevisionRef.current && + currentUserIdRef.current === snapshotOwnerUserId + ) { + setIsLoadingWalletLedger(false); + } + }); + }, [currentUserId]); const openWalletLedgerPanel = useCallback(() => { setIsWalletLedgerOpen(true); @@ -406,18 +661,42 @@ export function usePlatformProfileCenterController({ }, [loadWalletLedger]); const applyRechargeCenter = useCallback( - (center: ProfileRechargeCenterResponse) => { + ( + center: ProfileRechargeCenterResponse, + account: AccountLifecycle, + refreshWallet: boolean, + walletSnapshot: ReturnType, + ) => { + if ( + !isAccountLifecycleCurrent(account) || + usePlatformWalletStore.getState().ownerUserId !== account.ownerUserId + ) { + return false; + } rechargeCenterReadRevisionRef.current += 1; rechargeCenterReadAbortControllerRef.current?.abort(); rechargeCenterReadAbortControllerRef.current = null; setIsLoadingRechargeCenter(false); setRechargeError(null); setRechargeCenter(center); + if (center.mudPointBalance && walletSnapshot) { + applyWalletBalanceSnapshot(walletSnapshot, center.mudPointBalance); + } + if (refreshWallet || !center.mudPointBalance) { + void onWalletBalanceMayHaveChanged(); + } + return true; }, - [], + [ + applyWalletBalanceSnapshot, + isAccountLifecycleCurrent, + onWalletBalanceMayHaveChanged, + ], ); const loadRechargeCenter = useCallback(() => { + const account = captureAccountLifecycle(); + const walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId); const revision = ++rechargeCenterReadRevisionRef.current; rechargeCenterReadAbortControllerRef.current?.abort(); const abortController = new AbortController(); @@ -428,15 +707,17 @@ export function usePlatformProfileCenterController({ .then((center) => { if ( !abortController.signal.aborted && - revision === rechargeCenterReadRevisionRef.current + revision === rechargeCenterReadRevisionRef.current && + isAccountLifecycleCurrent(account) ) { - setRechargeCenter(center); + applyRechargeCenter(center, account, false, walletSnapshot); } }) .catch((error: unknown) => { if ( abortController.signal.aborted || - revision !== rechargeCenterReadRevisionRef.current + revision !== rechargeCenterReadRevisionRef.current || + !isAccountLifecycleCurrent(account) ) { return; } @@ -449,20 +730,38 @@ export function usePlatformProfileCenterController({ if (rechargeCenterReadAbortControllerRef.current === abortController) { rechargeCenterReadAbortControllerRef.current = null; } - if (revision === rechargeCenterReadRevisionRef.current) { + if ( + revision === rechargeCenterReadRevisionRef.current && + isAccountLifecycleCurrent(account) + ) { setIsLoadingRechargeCenter(false); } }); - }, []); + }, [ + applyRechargeCenter, + captureAccountLifecycle, + captureWalletBalanceSnapshot, + isAccountLifecycleCurrent, + ]); - const refreshRechargeState = useCallback(() => { - loadRechargeCenter(); - setSubmittingRechargeProductId(null); - pendingWechatRechargeOrderIdRef.current = null; - confirmingWechatRechargeOrderIdRef.current = null; - setWechatRechargeOrderConfirmationState(null); - setNativeWechatPayment(null); - }, [loadRechargeCenter]); + const refreshRechargeState = useCallback( + (account: AccountLifecycle) => { + if (!isAccountLifecycleCurrent(account)) { + return; + } + loadRechargeCenter(); + setSubmittingRechargeProductId(null); + abortRechargeOrders( + pendingWechatRechargeOrderRef.current, + confirmingWechatRechargeOrderRef.current, + ); + pendingWechatRechargeOrderRef.current = null; + confirmingWechatRechargeOrderRef.current = null; + setWechatRechargeOrderConfirmationState(null); + setNativeWechatPayment(null); + }, + [abortRechargeOrders, isAccountLifecycleCurrent, loadRechargeCenter], + ); const handleWechatPayResult = useCallback(() => { const payResult = readWechatPayResultFromHash(); @@ -470,36 +769,73 @@ export function usePlatformProfileCenterController({ return false; } + const pendingOrder = pendingWechatRechargeOrderRef.current; + if (pendingOrder && !isRechargeOrderCurrent(pendingOrder)) { + return false; + } if ( - pendingWechatRechargeOrderIdRef.current && + pendingOrder && payResult.orderId && - payResult.orderId !== pendingWechatRechargeOrderIdRef.current + payResult.orderId !== pendingOrder.orderId ) { return false; } + // 中文注释:无内存 pending order 的小程序回跳暂按当前账号上下文处理,后端订单 + // owner 校验仍是最终安全边界;跨 WebView 恢复若引入持久关联,必须同时校验 + // requestId、orderId 与 ownerUserId。 + const account = pendingOrder?.account ?? captureAccountLifecycle(); + if (!account.ownerUserId || !isAccountLifecycleCurrent(account)) { + return false; + } + if (payResult.status === 'success') { - const orderId = - payResult.orderId || pendingWechatRechargeOrderIdRef.current; + const orderId = payResult.orderId || pendingOrder?.orderId; if (!orderId) { clearWechatPayResultHash(); return true; } - if (confirmingWechatRechargeOrderIdRef.current === orderId) { + const order = + pendingOrder ?? createRechargeOrderLifecycle(orderId, account); + if ( + isSameRechargeOrder(confirmingWechatRechargeOrderRef.current, order) + ) { clearWechatPayResultHash(); return true; } - confirmingWechatRechargeOrderIdRef.current = orderId; + confirmingWechatRechargeOrderRef.current = order; setWechatRechargeOrderConfirmationState({ orderId }); setSubmittingRechargeProductId(null); setRechargePaymentResult(null); - void confirmWechatRechargeOrderUntilSettled(orderId) + const walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId); + void confirmWechatRechargeOrderUntilSettled( + orderId, + createRechargeConfirmationOptions(order), + ) .then((response) => { + if ( + !isRechargeOrderCurrent(order) || + !isSameRechargeOrder( + confirmingWechatRechargeOrderRef.current, + order, + ) + ) { + return; + } const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; - applyRechargeCenter(response.center); - pendingWechatRechargeOrderIdRef.current = null; - confirmingWechatRechargeOrderIdRef.current = null; + if ( + !applyRechargeCenter(response.center, account, true, walletSnapshot) + ) { + return; + } + if ( + isSameRechargeOrder(pendingWechatRechargeOrderRef.current, order) + ) { + pendingWechatRechargeOrderRef.current = null; + } + confirmingWechatRechargeOrderRef.current = null; + order.abortController.abort(); setWechatRechargeOrderConfirmationState(null); setRechargePaymentResult(result); if (isPaid) { @@ -508,7 +844,16 @@ export function usePlatformProfileCenterController({ clearWechatPayResultHash(); }) .catch(() => { - confirmingWechatRechargeOrderIdRef.current = null; + if ( + !isRechargeOrderCurrent(order) || + !isSameRechargeOrder( + confirmingWechatRechargeOrderRef.current, + order, + ) + ) { + return; + } + confirmingWechatRechargeOrderRef.current = null; setWechatRechargeOrderConfirmationState(null); setRechargePaymentResult({ kind: 'pending', @@ -524,7 +869,7 @@ export function usePlatformProfileCenterController({ message: '本次没有扣款,泥点余额未发生变化。', }); setWechatRechargeOrderConfirmationState(null); - refreshRechargeState(); + refreshRechargeState(account); } else { const detail = payResult.errorMessage ? `微信返回:${payResult.errorMessage}` @@ -535,12 +880,23 @@ export function usePlatformProfileCenterController({ message: detail, }); setWechatRechargeOrderConfirmationState(null); - refreshRechargeState(); + refreshRechargeState(account); } clearWechatPayResultHash(); return true; - }, [applyRechargeCenter, onRechargeSuccess, refreshRechargeState]); + }, [ + applyRechargeCenter, + captureAccountLifecycle, + captureWalletBalanceSnapshot, + createRechargeConfirmationOptions, + createRechargeOrderLifecycle, + isAccountLifecycleCurrent, + isRechargeOrderCurrent, + isSameRechargeOrder, + onRechargeSuccess, + refreshRechargeState, + ]); const pollWechatPayResultFromHash = useCallback( () => handleWechatPayResult(), @@ -552,21 +908,46 @@ export function usePlatformProfileCenterController({ return false; } - const orderId = pendingWechatRechargeOrderIdRef.current; - if (!orderId || confirmingWechatRechargeOrderIdRef.current === orderId) { + const order = pendingWechatRechargeOrderRef.current; + if ( + !isRechargeOrderCurrent(order) || + isSameRechargeOrder(confirmingWechatRechargeOrderRef.current, order) + ) { return false; } - confirmingWechatRechargeOrderIdRef.current = orderId; - setWechatRechargeOrderConfirmationState({ orderId }); + confirmingWechatRechargeOrderRef.current = order; + setWechatRechargeOrderConfirmationState({ orderId: order.orderId }); setRechargePaymentResult(null); - void confirmWechatRechargeOrderUntilSettled(orderId) + const walletSnapshot = captureWalletBalanceSnapshot( + order.account.ownerUserId, + ); + void confirmWechatRechargeOrderUntilSettled( + order.orderId, + createRechargeConfirmationOptions(order), + ) .then((response) => { + if ( + !isRechargeOrderCurrent(order) || + !isSameRechargeOrder(confirmingWechatRechargeOrderRef.current, order) + ) { + return; + } const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; - applyRechargeCenter(response.center); - pendingWechatRechargeOrderIdRef.current = null; - confirmingWechatRechargeOrderIdRef.current = null; + if ( + !applyRechargeCenter( + response.center, + order.account, + true, + walletSnapshot, + ) + ) { + return; + } + pendingWechatRechargeOrderRef.current = null; + confirmingWechatRechargeOrderRef.current = null; + order.abortController.abort(); setWechatRechargeOrderConfirmationState(null); setSubmittingRechargeProductId(null); setRechargePaymentResult(result); @@ -575,7 +956,13 @@ export function usePlatformProfileCenterController({ } }) .catch(() => { - confirmingWechatRechargeOrderIdRef.current = null; + if ( + !isRechargeOrderCurrent(order) || + !isSameRechargeOrder(confirmingWechatRechargeOrderRef.current, order) + ) { + return; + } + confirmingWechatRechargeOrderRef.current = null; setWechatRechargeOrderConfirmationState(null); setRechargePaymentResult({ kind: 'pending', @@ -584,7 +971,15 @@ export function usePlatformProfileCenterController({ }); }); return true; - }, [applyRechargeCenter, nativeWechatPayment, onRechargeSuccess]); + }, [ + applyRechargeCenter, + captureWalletBalanceSnapshot, + createRechargeConfirmationOptions, + isRechargeOrderCurrent, + isSameRechargeOrder, + nativeWechatPayment, + onRechargeSuccess, + ]); const openRechargeModal = useCallback(() => { if (!currentUser) { @@ -612,20 +1007,44 @@ export function usePlatformProfileCenterController({ }, [openRechargeModal, openRewardCodeModal, showRechargeEntry]); const closeNativeWechatPayment = useCallback(() => { - setNativeWechatPayment((current) => { - if (current?.isConfirming) { - return current; - } - pendingWechatRechargeOrderIdRef.current = null; - return null; - }); - }, []); + if (!nativeWechatPayment || nativeWechatPayment.isConfirming) { + return; + } + const pendingOrder = pendingWechatRechargeOrderRef.current; + if ( + pendingOrder?.orderId === nativeWechatPayment.orderId && + isRechargeOrderCurrent(pendingOrder) + ) { + pendingOrder.abortController.abort(); + pendingWechatRechargeOrderRef.current = null; + } + setNativeWechatPayment((current) => + current?.orderId === nativeWechatPayment.orderId && !current.isConfirming + ? null + : current, + ); + }, [isRechargeOrderCurrent, nativeWechatPayment]); const buyRechargeProduct = useCallback( (product: ProfileRechargeProduct) => { - if (submittingRechargeProductId) { + const account = captureAccountLifecycle(); + if ( + rechargeSubmissionRef.current || + submittingRechargeProductId || + !account.ownerUserId || + !isAccountLifecycleCurrent(account) + ) { return; } + const submissionToken = Symbol('profile-recharge-submission'); + rechargeSubmissionRef.current = submissionToken; + const requestSignal = accountWriteAbortControllerRef.current.signal; + if (requestSignal.aborted) { + rechargeSubmissionRef.current = null; + return; + } + const walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId); + let createdOrder: WechatRechargeOrderLifecycle | null = null; const paymentChannel = resolveProfileRechargeProductPaymentChannel( { kind: product.kind }, @@ -636,23 +1055,65 @@ export function usePlatformProfileCenterController({ setRechargePaymentResult(null); setWechatRechargeOrderConfirmationState(null); setNativeWechatPayment(null); - void createPlatformProfileRechargeOrder(product.productId, paymentChannel) + void createPlatformProfileRechargeOrder( + product.productId, + paymentChannel, + { + signal: requestSignal, + }, + ) .then(async (response) => { + if (!isAccountLifecycleCurrent(account)) { + return; + } + const order = createRechargeOrderLifecycle( + response.order.orderId, + account, + ); + abortRechargeOrders( + pendingWechatRechargeOrderRef.current, + confirmingWechatRechargeOrderRef.current, + ); + createdOrder = order; + pendingWechatRechargeOrderRef.current = order; + confirmingWechatRechargeOrderRef.current = null; + if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) { - pendingWechatRechargeOrderIdRef.current = response.order.orderId; - applyRechargeCenter(response.center); + if ( + !applyRechargeCenter( + response.center, + account, + true, + walletSnapshot, + ) || + !isRechargeOrderCurrent(order) + ) { + return; + } const paymentHandled = await requestHostPayment({ payload: response.wechatMiniProgramPayParams, orderId: response.order.orderId, }); + if (!isRechargeOrderCurrent(order)) { + return; + } if (!paymentHandled) { throw new Error('请在微信小程序内完成支付'); } return; } if (paymentChannel === WECHAT_JSAPI_PAYMENT_CHANNEL) { - pendingWechatRechargeOrderIdRef.current = response.order.orderId; - applyRechargeCenter(response.center); + if ( + !applyRechargeCenter( + response.center, + account, + true, + walletSnapshot, + ) || + !isRechargeOrderCurrent(order) + ) { + return; + } setRechargePaymentResult({ kind: 'pending', title: '正在打开微信支付', @@ -661,27 +1122,59 @@ export function usePlatformProfileCenterController({ await requestWechatJsapiPayment( response.wechatMiniProgramPayParams, ); + if (!isRechargeOrderCurrent(order)) { + return; + } setRechargePaymentResult({ kind: 'pending', title: '支付处理中', message: '正在查询微信支付到账状态。', }); - void confirmWechatRechargeOrderUntilSettled(response.order.orderId) + const confirmationWalletSnapshot = captureWalletBalanceSnapshot( + account.ownerUserId, + ); + void confirmWechatRechargeOrderUntilSettled( + response.order.orderId, + createRechargeConfirmationOptions(order), + ) .then((confirmResponse) => { + if (!isRechargeOrderCurrent(order)) { + return; + } const result = buildRechargePaymentResultForOrder( confirmResponse.order, ); const isPaid = result.kind === 'success'; - applyRechargeCenter(confirmResponse.center); + if ( + !applyRechargeCenter( + confirmResponse.center, + account, + true, + confirmationWalletSnapshot, + ) + ) { + return; + } setRechargePaymentResult(result); if (result.kind !== 'pending') { - pendingWechatRechargeOrderIdRef.current = null; + if ( + isSameRechargeOrder( + pendingWechatRechargeOrderRef.current, + order, + ) + ) { + pendingWechatRechargeOrderRef.current = null; + } + order.abortController.abort(); } if (isPaid) { void onRechargeSuccess?.(); } }) .catch(() => { + if (!isRechargeOrderCurrent(order)) { + return; + } setRechargePaymentResult({ kind: 'pending', title: '等待微信确认', @@ -695,13 +1188,25 @@ export function usePlatformProfileCenterController({ if (!h5Url) { throw new Error('微信 H5 支付链接生成失败'); } - pendingWechatRechargeOrderIdRef.current = response.order.orderId; - applyRechargeCenter(response.center); + if ( + !applyRechargeCenter( + response.center, + account, + true, + walletSnapshot, + ) || + !isRechargeOrderCurrent(order) + ) { + return; + } setRechargePaymentResult({ kind: 'pending', title: '正在打开微信支付', message: '完成支付后返回页面确认到账状态。', }); + if (!isRechargeOrderCurrent(order)) { + return; + } await redirectToPaymentUrl(h5Url); return; } @@ -712,8 +1217,17 @@ export function usePlatformProfileCenterController({ if (!wechatNativePayment || !codeUrl || !expiresAt) { throw new Error('微信 Native 支付链接生成失败'); } - pendingWechatRechargeOrderIdRef.current = response.order.orderId; - applyRechargeCenter(response.center); + if ( + !applyRechargeCenter( + response.center, + account, + true, + walletSnapshot, + ) || + !isRechargeOrderCurrent(order) + ) { + return; + } setNativeWechatPayment({ ...wechatNativePayment, codeUrl, @@ -730,7 +1244,19 @@ export function usePlatformProfileCenterController({ throw new Error('充值支付渠道无效'); }) .catch((error: unknown) => { - pendingWechatRechargeOrderIdRef.current = null; + if (!isAccountLifecycleCurrent(account)) { + return; + } + if ( + createdOrder && + isSameRechargeOrder( + pendingWechatRechargeOrderRef.current, + createdOrder, + ) + ) { + createdOrder.abortController.abort(); + pendingWechatRechargeOrderRef.current = null; + } setNativeWechatPayment(null); if ( paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL && @@ -746,6 +1272,9 @@ export function usePlatformProfileCenterController({ message: '正在打开微信小程序登录,请重新登录后再支付。', }); void requestHostLogin().catch((miniProgramLoginError: unknown) => { + if (!isAccountLifecycleCurrent(account)) { + return; + } setRechargePaymentResult(null); setRechargeError( miniProgramLoginError instanceof Error @@ -766,6 +1295,9 @@ export function usePlatformProfileCenterController({ message: '正在跳转微信授权,授权后请重新发起支付。', }); void startWechatBind().catch((wechatLoginError: unknown) => { + if (!isAccountLifecycleCurrent(account)) { + return; + } setRechargePaymentResult(null); setRechargeError( wechatLoginError instanceof Error @@ -777,35 +1309,76 @@ export function usePlatformProfileCenterController({ } setRechargeError(error instanceof Error ? error.message : '充值失败'); setSubmittingRechargeProductId(null); + }) + .finally(() => { + if (rechargeSubmissionRef.current === submissionToken) { + rechargeSubmissionRef.current = null; + } }); }, - [applyRechargeCenter, onRechargeSuccess, submittingRechargeProductId], + [ + applyRechargeCenter, + abortRechargeOrders, + captureAccountLifecycle, + captureWalletBalanceSnapshot, + createRechargeConfirmationOptions, + createRechargeOrderLifecycle, + isAccountLifecycleCurrent, + isRechargeOrderCurrent, + isSameRechargeOrder, + onRechargeSuccess, + submittingRechargeProductId, + ], ); const confirmNativeWechatPayment = useCallback(() => { if (!nativeWechatPayment || nativeWechatPayment.isConfirming) { return; } + const order = pendingWechatRechargeOrderRef.current; + if ( + !isRechargeOrderCurrent(order) || + order.orderId !== nativeWechatPayment.orderId + ) { + return; + } setNativeWechatPayment((current) => current && current.orderId === nativeWechatPayment.orderId ? { ...current, isConfirming: true, confirmMessage: undefined } : current, ); - void confirmWechatRechargeOrderQuickly(nativeWechatPayment.orderId) + const walletSnapshot = captureWalletBalanceSnapshot( + order.account.ownerUserId, + ); + void confirmWechatRechargeOrderQuickly( + nativeWechatPayment.orderId, + createRechargeConfirmationOptions(order), + ) .then((response) => { - if ( - pendingWechatRechargeOrderIdRef.current !== - nativeWechatPayment.orderId - ) { + if (!isRechargeOrderCurrent(order)) { return; } const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; - applyRechargeCenter(response.center); + if ( + !applyRechargeCenter( + response.center, + order.account, + true, + walletSnapshot, + ) + ) { + return; + } if (result.kind !== 'pending') { setNativeWechatPayment(null); - pendingWechatRechargeOrderIdRef.current = null; + if ( + isSameRechargeOrder(pendingWechatRechargeOrderRef.current, order) + ) { + pendingWechatRechargeOrderRef.current = null; + } + order.abortController.abort(); setRechargePaymentResult(result); if (isPaid) { void onRechargeSuccess?.(); @@ -823,6 +1396,9 @@ export function usePlatformProfileCenterController({ } }) .catch(() => { + if (!isRechargeOrderCurrent(order)) { + return; + } setNativeWechatPayment((current) => current && current.orderId === nativeWechatPayment.orderId ? { @@ -833,46 +1409,106 @@ export function usePlatformProfileCenterController({ : current, ); }) - .finally(() => setSubmittingRechargeProductId(null)); - }, [applyRechargeCenter, nativeWechatPayment, onRechargeSuccess]); + .finally(() => { + if (isRechargeOrderCurrent(order)) { + setSubmittingRechargeProductId(null); + } + }); + }, [ + applyRechargeCenter, + captureWalletBalanceSnapshot, + createRechargeConfirmationOptions, + isRechargeOrderCurrent, + isSameRechargeOrder, + nativeWechatPayment, + onRechargeSuccess, + ]); useEffect(() => { const orderId = nativeWechatPayment?.orderId; const expiresAtMs = Date.parse(nativeWechatPayment?.expiresAt ?? ''); - if (!orderId || !Number.isFinite(expiresAtMs)) { + const order = pendingWechatRechargeOrderRef.current; + if ( + !orderId || + !Number.isFinite(expiresAtMs) || + !isRechargeOrderCurrent(order) || + order.orderId !== orderId + ) { return undefined; } let cancelled = false; - const abortController = new AbortController(); + const orderConfirmationOptions = createRechargeConfirmationOptions(order); + const effectAbortController = new AbortController(); + const abortEffectRequest = () => { + effectAbortController.abort(orderConfirmationOptions.signal.reason); + }; + if (orderConfirmationOptions.signal.aborted) { + abortEffectRequest(); + } else { + orderConfirmationOptions.signal.addEventListener( + 'abort', + abortEffectRequest, + { once: true }, + ); + } + const confirmationOptions = { + ...orderConfirmationOptions, + signal: effectAbortController.signal, + }; const watchUntilSettled = async () => { - while (!cancelled && Date.now() < expiresAtMs) { + while ( + !cancelled && + Date.now() < expiresAtMs && + confirmationOptions.isCurrent() + ) { try { + assertWechatRechargeConfirmationActive(confirmationOptions); + const walletSnapshot = captureWalletBalanceSnapshot( + order.account.ownerUserId, + ); const response = await watchWechatPlatformProfileRechargeOrder( orderId, { - signal: abortController.signal, + signal: confirmationOptions.signal, }, ); + assertWechatRechargeConfirmationActive(confirmationOptions); if ( cancelled || !response || - pendingWechatRechargeOrderIdRef.current !== orderId + !isSameRechargeOrder(pendingWechatRechargeOrderRef.current, order) ) { return; } const result = buildRechargePaymentResultForOrder(response.order); - applyRechargeCenter(response.center); + if ( + !applyRechargeCenter( + response.center, + order.account, + true, + walletSnapshot, + ) + ) { + return; + } if (result.kind === 'pending') { - await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS); + await waitWechatPayConfirmDelay( + WECHAT_NATIVE_WATCH_RETRY_DELAY_MS, + confirmationOptions.signal, + ); + assertWechatRechargeConfirmationActive(confirmationOptions); continue; } - pendingWechatRechargeOrderIdRef.current = null; - if (confirmingWechatRechargeOrderIdRef.current === orderId) { - confirmingWechatRechargeOrderIdRef.current = null; + pendingWechatRechargeOrderRef.current = null; + if ( + isSameRechargeOrder(confirmingWechatRechargeOrderRef.current, order) + ) { + confirmingWechatRechargeOrderRef.current = null; } + order.abortController.abort(); setNativeWechatPayment((current) => current?.orderId === orderId ? null : current, ); @@ -883,24 +1519,44 @@ export function usePlatformProfileCenterController({ } return; } catch { - if (cancelled || abortController.signal.aborted) { + if ( + cancelled || + confirmationOptions.signal.aborted || + !confirmationOptions.isCurrent() + ) { return; } } - await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS); + try { + await waitWechatPayConfirmDelay( + WECHAT_NATIVE_WATCH_RETRY_DELAY_MS, + confirmationOptions.signal, + ); + assertWechatRechargeConfirmationActive(confirmationOptions); + } catch { + return; + } } }; void watchUntilSettled(); return () => { cancelled = true; - abortController.abort(); + orderConfirmationOptions.signal.removeEventListener( + 'abort', + abortEffectRequest, + ); + effectAbortController.abort(); }; }, [ nativeWechatPayment?.expiresAt, nativeWechatPayment?.orderId, applyRechargeCenter, + captureWalletBalanceSnapshot, + createRechargeConfirmationOptions, + isRechargeOrderCurrent, + isSameRechargeOrder, onRechargeSuccess, ]); @@ -971,21 +1627,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) { @@ -1053,20 +1729,55 @@ export function usePlatformProfileCenterController({ setIsSubmittingReferralRedeem(true); setReferralError(null); setReferralSuccess(null); - void redeemPlatformProfileReferralInviteCode(inviteCode) + const snapshotOwnerUserId = currentUserId; + const accountRevision = accountLifecycleRevisionRef.current; + const requestSignal = accountWriteAbortControllerRef.current.signal; + if (requestSignal.aborted) { + setIsSubmittingReferralRedeem(false); + return; + } + void redeemPlatformProfileReferralInviteCode(inviteCode, { + signal: requestSignal, + }) .then((response) => { + if ( + accountRevision !== accountLifecycleRevisionRef.current || + currentUserIdRef.current !== snapshotOwnerUserId + ) { + return; + } setReferralCenter(response.center); setReferralRedeemCode(''); setReferralSuccess('已填写'); + void onWalletBalanceMayHaveChanged(); void onRechargeSuccess?.(); }) .catch((error: unknown) => { + if ( + accountRevision !== accountLifecycleRevisionRef.current || + currentUserIdRef.current !== snapshotOwnerUserId + ) { + return; + } setReferralError( error instanceof Error ? error.message : '填写邀请码失败', ); }) - .finally(() => setIsSubmittingReferralRedeem(false)); - }, [isSubmittingReferralRedeem, onRechargeSuccess, referralRedeemCode]); + .finally(() => { + if ( + accountRevision === accountLifecycleRevisionRef.current && + currentUserIdRef.current === snapshotOwnerUserId + ) { + setIsSubmittingReferralRedeem(false); + } + }); + }, [ + currentUserId, + isSubmittingReferralRedeem, + onRechargeSuccess, + onWalletBalanceMayHaveChanged, + referralRedeemCode, + ]); const submitRewardCode = useCallback(() => { if (isSubmittingRewardCode || !rewardCodeInput.trim()) { @@ -1076,17 +1787,64 @@ export function usePlatformProfileCenterController({ setIsSubmittingRewardCode(true); setRewardCodeError(null); setRewardCodeSuccess(null); - void redeemPlatformProfileRewardCode(rewardCodeInput) + const snapshotOwnerUserId = currentUserId; + const accountRevision = accountLifecycleRevisionRef.current; + const requestSignal = accountWriteAbortControllerRef.current.signal; + if (requestSignal.aborted) { + setIsSubmittingRewardCode(false); + return; + } + void redeemPlatformProfileRewardCode(rewardCodeInput, { + signal: requestSignal, + }) .then((response: RedeemProfileRewardCodeResponse) => { + if ( + accountRevision !== accountLifecycleRevisionRef.current || + currentUserIdRef.current !== snapshotOwnerUserId + ) { + return; + } setRewardCodeInput(''); setRewardCodeSuccess(`已到账 ${response.amountGranted} 泥点`); + void onWalletBalanceMayHaveChanged(); void onRechargeSuccess?.(); }) .catch((error: unknown) => { + if ( + accountRevision !== accountLifecycleRevisionRef.current || + currentUserIdRef.current !== snapshotOwnerUserId + ) { + return; + } setRewardCodeError(error instanceof Error ? error.message : '兑换失败'); }) - .finally(() => setIsSubmittingRewardCode(false)); - }, [isSubmittingRewardCode, onRechargeSuccess, rewardCodeInput]); + .finally(() => { + if ( + accountRevision === accountLifecycleRevisionRef.current && + currentUserIdRef.current === snapshotOwnerUserId + ) { + setIsSubmittingRewardCode(false); + } + }); + }, [ + currentUserId, + isSubmittingRewardCode, + onRechargeSuccess, + onWalletBalanceMayHaveChanged, + rewardCodeInput, + ]); + + const rechargeModalCenter = useMemo(() => { + if (!walletOwnerMatchesCurrentUser || !rechargeCenter) { + return null; + } + return { + ...rechargeCenter, + walletBalance: + mudPointBalance?.totalPoints ?? rechargeCenter.walletBalance, + mudPointBalance: mudPointBalance ?? rechargeCenter.mudPointBalance, + }; + }, [mudPointBalance, rechargeCenter, walletOwnerMatchesCurrentUser]); return { closeNativeWechatPayment, @@ -1110,7 +1868,7 @@ export function usePlatformProfileCenterController({ openRewardCodeModal, openWalletLedgerPanel, profilePopupPanel, - rechargeCenter, + rechargeCenter: rechargeModalCenter, rechargeError, rechargePaymentResult, referralCenter, diff --git a/src/components/platform-entry/usePlatformProfileCenterController.walletLedger.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.walletLedger.test.tsx new file mode 100644 index 000000000..3efcfc2da --- /dev/null +++ b/src/components/platform-entry/usePlatformProfileCenterController.walletLedger.test.tsx @@ -0,0 +1,33 @@ +/* @vitest-environment jsdom */ + +import { act, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; +import { + profileClientMocks, + renderController, + userA, +} from './usePlatformProfileCenterController.testSupport'; + +describe('usePlatformProfileCenterController wallet ledger', () => { + beforeEach(() => { + window.history.replaceState(null, '', '/profile'); + usePlatformWalletStore.getState().resetWalletBalance(); + vi.clearAllMocks(); + }); + + test('settles a valid read without depending on wallet-store owner initialization', async () => { + profileClientMocks.getPlatformProfileWalletLedger.mockResolvedValue({ + entries: [], + }); + const { result } = renderController(userA); + + act(() => result.current.loadWalletLedger()); + + await waitFor(() => { + expect(result.current.walletLedger).toEqual({ entries: [] }); + expect(result.current.isLoadingWalletLedger).toBe(false); + }); + }); +}); diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index 8bd1ba54a..c1021f0d0 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -662,6 +662,168 @@ describe('apiClient', () => { ); }); + it('does not retry an unsafe write with a new token after its account signal aborts', async () => { + setStoredAccessToken('account-a-token', { emit: false }); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 503 })) + .mockResolvedValueOnce(createResponseMock({ status: 200 })); + const abortController = new AbortController(); + + const request = requestJson( + '/api/profile/redeem-codes/redeem', + { + method: 'POST', + body: JSON.stringify({ code: 'REWARD01' }), + signal: abortController.signal, + }, + '兑换失败', + { + retry: { + maxRetries: 1, + baseDelayMs: 1000, + maxDelayMs: 1000, + retryUnsafeMethods: true, + }, + }, + ); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + setStoredAccessToken('account-b-token', { emit: false }); + abortController.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect( + (fetchMock.mock.calls[0]?.[1]?.headers as Record) + .Authorization, + ).toBe('Bearer account-a-token'); + }); + + it('does not replay a 401 unsafe write after its account signal aborts during refresh', async () => { + setStoredAccessToken('account-a-token', { emit: false }); + const refreshResponse = + createDeferred>(); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockImplementationOnce(() => refreshResponse.promise) + .mockResolvedValueOnce(createResponseMock({ status: 200 })); + const abortController = new AbortController(); + + const request = requestJson( + '/api/profile/redeem-codes/redeem', + { + method: 'POST', + body: JSON.stringify({ code: 'REWARD01' }), + signal: abortController.signal, + }, + '兑换失败', + ); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + const accountARefresh = refreshStoredAccessToken({ + clearOnFailure: false, + }); + const accountARefreshResult = expect(accountARefresh).rejects.toMatchObject( + { + name: 'AuthStateChangedDuringRefreshError', + }, + ); + setStoredAccessToken('account-b-token', { emit: false }); + abortController.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + refreshResponse.resolve( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { token: 'late-refresh-token' }, + error: null, + meta: { apiVersion: '2026-06-16' }, + }), + }), + ); + await accountARefreshResult; + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(getStoredAccessToken()).toBe('account-b-token'); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/api/profile/redeem-codes/redeem', + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/auth/refresh'); + }); + + it('starts a separate refresh for a new auth generation', async () => { + setStoredAccessToken('account-a-token', { emit: false }); + const accountAResponse = + createDeferred>(); + const accountBResponse = + createDeferred>(); + fetchMock + .mockImplementationOnce(() => accountAResponse.promise) + .mockImplementationOnce(() => accountBResponse.promise); + + const accountARefresh = refreshStoredAccessToken({ clearOnFailure: false }); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + setStoredAccessToken('account-b-token', { emit: false }); + const accountBRefresh = refreshStoredAccessToken({ clearOnFailure: false }); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + accountAResponse.resolve( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { token: 'late-account-a-token' }, + error: null, + meta: { apiVersion: '2026-06-16' }, + }), + }), + ); + await expect(accountARefresh).rejects.toMatchObject({ + name: 'AuthStateChangedDuringRefreshError', + }); + expect(getStoredAccessToken()).toBe('account-b-token'); + + accountBResponse.resolve( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { token: 'fresh-account-b-token' }, + error: null, + meta: { apiVersion: '2026-06-16' }, + }), + }), + ); + await expect(accountBRefresh).resolves.toBe('fresh-account-b-token'); + expect(getStoredAccessToken()).toBe('fresh-account-b-token'); + }); + + it('does not clear a new account token when an old refresh fails late', async () => { + setStoredAccessToken('account-a-token', { emit: false }); + const accountAResponse = + createDeferred>(); + fetchMock.mockImplementationOnce(() => accountAResponse.promise); + + const accountARefresh = refreshStoredAccessToken(); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + setStoredAccessToken('account-b-token', { emit: false }); + accountAResponse.resolve(createResponseMock({ status: 401 })); + + await expect(accountARefresh).rejects.toMatchObject({ status: 401 }); + expect(getStoredAccessToken()).toBe('account-b-token'); + }); + it('aborts requests when timeoutMs is reached', async () => { setStoredAccessToken('timeout-token', { emit: false }); fetchMock.mockImplementation( @@ -700,9 +862,8 @@ describe('apiClient', () => { it.each(['missing-token', 'unauthorized'] as const)( 'bounds the %s refresh wait with an absolute request deadline', async (refreshMode) => { - const refreshResponse = createDeferred< - ReturnType - >(); + const refreshResponse = + createDeferred>(); if (refreshMode === 'unauthorized') { setStoredAccessToken('expired-token', { emit: false }); fetchMock @@ -853,9 +1014,13 @@ describe('apiClient', () => { ); await expect( - requestJson('/api/runtime/puzzle/agent/sessions/test/actions', { - method: 'POST', - }, '执行拼图操作失败。'), + requestJson( + '/api/runtime/puzzle/agent/sessions/test/actions', + { + method: 'POST', + }, + '执行拼图操作失败。', + ), ).rejects.toMatchObject({ message: '拼图图片生成失败:请求参数不合法', status: 400, @@ -895,9 +1060,13 @@ describe('apiClient', () => { ); await expect( - requestJson('/api/runtime/puzzle/agent/sessions/test/actions', { - method: 'POST', - }, '执行拼图操作失败。'), + requestJson( + '/api/runtime/puzzle/agent/sessions/test/actions', + { + method: 'POST', + }, + '执行拼图操作失败。', + ), ).rejects.toMatchObject({ message: '无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置', diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 94211f5e0..880e52921 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -114,7 +114,9 @@ function normalizeHeaders(headers?: HeadersInit) { function hasHeader(headers: Record, name: string) { const normalizedName = name.toLowerCase(); - return Object.keys(headers).some((key) => key.toLowerCase() === normalizedName); + return Object.keys(headers).some( + (key) => key.toLowerCase() === normalizedName, + ); } function setHeaderIfMissing( @@ -146,7 +148,11 @@ function attachHostRuntimeHeaders(headers: Record) { runtime.clientRuntime || 'wechat_mini_program', ); setHeaderIfMissing(headers, CLIENT_PLATFORM_HEADER, runtime.hostPlatform); - setHeaderIfMissing(headers, MINI_PROGRAM_ENV_HEADER, runtime.miniProgramEnv); + setHeaderIfMissing( + headers, + MINI_PROGRAM_ENV_HEADER, + runtime.miniProgramEnv, + ); return headers; } @@ -171,7 +177,10 @@ function buildClientRequestId() { return `web-${randomId}`; } -function resolveRequestIdHeader(headers: Record, options: ApiRequestOptions) { +function resolveRequestIdHeader( + headers: Record, + options: ApiRequestOptions, +) { const explicitRequestId = options.requestId?.trim(); const existingRequestId = Object.entries(headers).find( ([key, value]) => key.toLowerCase() === REQUEST_ID_HEADER && value.trim(), @@ -630,7 +639,9 @@ export function getStoredAccessToken() { return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || ''; } -export function setStoredAccessToken( +let authStateGeneration = 0; + +function writeStoredAccessToken( token: string, options: { emit?: boolean; @@ -654,6 +665,20 @@ export function setStoredAccessToken( } } +export function setStoredAccessToken( + token: string, + options: { + emit?: boolean; + } = {}, +) { + if (!canUseLocalStorage()) { + return; + } + + authStateGeneration += 1; + writeStoredAccessToken(token, options); +} + export function clearStoredAccessToken( options: { emit?: boolean; @@ -663,6 +688,7 @@ export function clearStoredAccessToken( return; } + authStateGeneration += 1; const previousToken = getStoredAccessToken(); window.localStorage.removeItem(ACCESS_TOKEN_KEY); @@ -687,7 +713,50 @@ function withAuthorizationHeaders( return nextHeaders; } -let refreshAccessTokenPromise: Promise | null = null; +type AuthStateSnapshot = { + generation: number; + accessToken: string; +}; + +type RefreshAccessTokenAttempt = AuthStateSnapshot & { + promise: Promise; +}; + +class AuthStateChangedDuringRefreshError extends Error { + constructor() { + super('刷新期间登录状态已变化'); + this.name = 'AuthStateChangedDuringRefreshError'; + } +} + +let refreshAccessTokenAttempt: RefreshAccessTokenAttempt | null = null; + +function captureAuthStateSnapshot(): AuthStateSnapshot { + return { + generation: authStateGeneration, + accessToken: getStoredAccessToken(), + }; +} + +function isCurrentAuthState(snapshot: AuthStateSnapshot) { + return ( + authStateGeneration === snapshot.generation && + getStoredAccessToken() === snapshot.accessToken + ); +} + +function publishRefreshedAccessToken( + nextToken: string, + snapshot: AuthStateSnapshot, +) { + if (!isCurrentAuthState(snapshot)) { + throw new AuthStateChangedDuringRefreshError(); + } + + // refresh 只轮换同一账号的 access token,不推进账号代际。 + // 外部登录、切号或退出通过公开 setter 推进代际,使旧 refresh 发布失效。 + writeStoredAccessToken(nextToken, { emit: false }); +} function shouldClearAuthAfterRefreshFailure(error: unknown) { return ( @@ -697,11 +766,16 @@ function shouldClearAuthAfterRefreshFailure(error: unknown) { } async function refreshAccessToken() { - if (refreshAccessTokenPromise) { - return refreshAccessTokenPromise; + const authStateSnapshot = captureAuthStateSnapshot(); + if ( + refreshAccessTokenAttempt && + refreshAccessTokenAttempt.generation === authStateSnapshot.generation && + refreshAccessTokenAttempt.accessToken === authStateSnapshot.accessToken + ) { + return refreshAccessTokenAttempt.promise; } - refreshAccessTokenPromise = (async () => { + const promise = (async () => { const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin', @@ -726,14 +800,21 @@ async function refreshAccessToken() { throw new Error('刷新登录状态失败'); } - setStoredAccessToken(nextToken, { emit: false }); + publishRefreshedAccessToken(nextToken, authStateSnapshot); return nextToken; })(); + const attempt: RefreshAccessTokenAttempt = { + ...authStateSnapshot, + promise, + }; + refreshAccessTokenAttempt = attempt; try { - return await refreshAccessTokenPromise; + return await promise; } finally { - refreshAccessTokenPromise = null; + if (refreshAccessTokenAttempt === attempt) { + refreshAccessTokenAttempt = null; + } } } @@ -752,12 +833,14 @@ export async function refreshStoredAccessToken( clearOnFailure?: boolean; } = {}, ) { + const authStateSnapshot = captureAuthStateSnapshot(); try { return await refreshAccessToken(); } catch (error) { if ( options.clearOnFailure !== false && - shouldClearAuthAfterRefreshFailure(error) + shouldClearAuthAfterRefreshFailure(error) && + isCurrentAuthState(authStateSnapshot) ) { clearStoredAccessToken({ emit: false }); } @@ -774,7 +857,10 @@ export async function fetchWithApiAuth( const retry = resolveRetryOptions(method, options.retry); const authFailurePolicy = resolveAuthFailurePolicy(options); const requestSignal = init.signal ?? undefined; - const requestId = resolveRequestIdHeader(normalizeHeaders(init.headers), options); + const requestId = resolveRequestIdHeader( + normalizeHeaders(init.headers), + options, + ); let attempt = 0; let refreshAttempted = false; @@ -830,6 +916,7 @@ export async function fetchWithApiAuth( !authFailurePolicy.skipRefresh && !refreshAttempted ) { + const refreshAuthStateSnapshot = captureAuthStateSnapshot(); try { await awaitWithAbortSignal(refreshAccessToken(), requestSignal); refreshAttempted = true; @@ -844,7 +931,8 @@ export async function fetchWithApiAuth( const shouldClearAuth = hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized && - shouldClearAuthAfterRefreshFailure(refreshError); + shouldClearAuthAfterRefreshFailure(refreshError) && + isCurrentAuthState(refreshAuthStateSnapshot); if (shouldClearAuth) { clearStoredAccessToken({ emit: false }); } @@ -897,7 +985,9 @@ async function buildApiClientError( const baseMessage = parseApiErrorMessage(responseText, fallbackMessage); return new ApiClientError({ - message: requestId ? `${baseMessage}(requestId: ${requestId})` : baseMessage, + message: requestId + ? `${baseMessage}(requestId: ${requestId})` + : baseMessage, status: response.status, code: parsedError?.code ?? `HTTP_${response.status || 0}`, details: parsedError?.details ?? null, diff --git a/src/services/platform-entry/platformProfileClient.test.ts b/src/services/platform-entry/platformProfileClient.test.ts new file mode 100644 index 000000000..f36b4a5fc --- /dev/null +++ b/src/services/platform-entry/platformProfileClient.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const apiClientMocks = vi.hoisted(() => ({ + fetchWithApiAuth: vi.fn(), + requestJson: vi.fn(), +})); + +vi.mock('../apiClient', () => apiClientMocks); + +import { + createPlatformProfileRechargeOrder, + getPlatformProfileRechargeCenter, + redeemPlatformProfileReferralInviteCode, + redeemPlatformProfileRewardCode, +} from './platformProfileClient'; + +describe('platformProfileClient', () => { + beforeEach(() => { + apiClientMocks.requestJson.mockReset(); + apiClientMocks.requestJson.mockResolvedValue({}); + }); + + test('forwards the recharge-center abort signal through request options', async () => { + const abortController = new AbortController(); + + await getPlatformProfileRechargeCenter({ + signal: abortController.signal, + }); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/profile/recharge-center', + { + method: 'GET', + signal: abortController.signal, + }, + '读取泥点购买信息失败', + expect.objectContaining({ + retry: expect.any(Object), + }), + ); + }); + + test.each([ + [ + '充值下单', + (signal: AbortSignal) => + createPlatformProfileRechargeOrder('points-60', 'wechat_native', { + signal, + }), + ], + [ + '邀请码兑换', + (signal: AbortSignal) => + redeemPlatformProfileReferralInviteCode('INVITE01', { signal }), + ], + [ + '奖励码兑换', + (signal: AbortSignal) => + redeemPlatformProfileRewardCode('REWARD01', { signal }), + ], + ])('%s write forwards its account abort signal', async (_label, request) => { + const abortController = new AbortController(); + + await request(abortController.signal); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + expect.stringMatching(/^\/api\/profile\//u), + expect.objectContaining({ signal: abortController.signal }), + expect.any(String), + expect.objectContaining({ retry: expect.any(Object) }), + ); + }); +}); diff --git a/src/stores/usePlatformWalletStore.test.tsx b/src/stores/usePlatformWalletStore.test.tsx new file mode 100644 index 000000000..2b133a265 --- /dev/null +++ b/src/stores/usePlatformWalletStore.test.tsx @@ -0,0 +1,85 @@ +/* @vitest-environment jsdom */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { type ReactNode, StrictMode } from 'react'; +import { beforeEach, expect, test, vi } from 'vitest'; + +const profileClientMocks = vi.hoisted(() => ({ + getPlatformProfileRechargeCenter: vi.fn(), +})); + +vi.mock('../services/platform-entry/platformProfileClient', () => ({ + getPlatformProfileRechargeCenter: + profileClientMocks.getPlatformProfileRechargeCenter, +})); + +import { + usePlatformWalletLifecycle, + usePlatformWalletStore, +} from './usePlatformWalletStore'; + +beforeEach(() => { + vi.clearAllMocks(); + usePlatformWalletStore.getState().resetWalletBalance(); + profileClientMocks.getPlatformProfileRechargeCenter.mockResolvedValue({ + walletBalance: 42, + mudPointBalance: { + totalPoints: 42, + permanentPoints: 42, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-08T00:00:00+08:00', + }, + }); +}); + +test('the single lifecycle survives StrictMode replay and resets on root unmount', async () => { + const addWindowListener = vi.spyOn(window, 'addEventListener'); + const removeWindowListener = vi.spyOn(window, 'removeEventListener'); + const { unmount } = renderHook( + () => usePlatformWalletLifecycle('user-1', true), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + + await waitFor(() => { + expect(usePlatformWalletStore.getState()).toMatchObject({ + ownerUserId: 'user-1', + mudPointBalance: expect.objectContaining({ totalPoints: 42 }), + legacyWalletBalance: 42, + mudPointBalanceStatus: 'ready', + }); + }); + + const requestsBeforeFocus = + profileClientMocks.getPlatformProfileRechargeCenter.mock.calls.length; + await act(async () => { + window.dispatchEvent(new Event('focus')); + }); + await waitFor(() => { + expect( + profileClientMocks.getPlatformProfileRechargeCenter.mock.calls.length, + ).toBeGreaterThan(requestsBeforeFocus); + }); + + unmount(); + + expect(usePlatformWalletStore.getState()).toMatchObject({ + ownerUserId: null, + mudPointBalance: null, + legacyWalletBalance: null, + mudPointBalanceStatus: 'idle', + }); + expect( + addWindowListener.mock.calls.filter(([eventName]) => eventName === 'focus'), + ).toHaveLength( + removeWindowListener.mock.calls.filter( + ([eventName]) => eventName === 'focus', + ).length, + ); +}); diff --git a/src/stores/usePlatformWalletStore.ts b/src/stores/usePlatformWalletStore.ts new file mode 100644 index 000000000..6d6da60f4 --- /dev/null +++ b/src/stores/usePlatformWalletStore.ts @@ -0,0 +1,68 @@ +import { useEffect } from 'react'; + +import { createProfileWalletStore } from '@/packages/shared/src/stores/createProfileWalletStore'; +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, + ); + const resetWalletBalance = usePlatformWalletStore( + (state) => state.resetWalletBalance, + ); + + useEffect(() => { + const ownerUserId = + canAccessProtectedData && currentUserId ? currentUserId : null; + setWalletOwner(ownerUserId); + if (!ownerUserId) { + return () => { + resetWalletBalance(); + }; + } + + void onWalletBalanceMayHaveChanged(); + let foregroundRefreshTimer: number | null = null; + const scheduleWalletRefresh = () => { + if (foregroundRefreshTimer !== null) { + return; + } + foregroundRefreshTimer = window.setTimeout(() => { + foregroundRefreshTimer = null; + void onWalletBalanceMayHaveChanged(); + }, 0); + }; + const refreshVisibleWallet = () => { + if (document.visibilityState === 'visible') { + scheduleWalletRefresh(); + } + }; + + window.addEventListener('focus', scheduleWalletRefresh); + document.addEventListener('visibilitychange', refreshVisibleWallet); + return () => { + window.removeEventListener('focus', scheduleWalletRefresh); + document.removeEventListener('visibilitychange', refreshVisibleWallet); + if (foregroundRefreshTimer !== null) { + window.clearTimeout(foregroundRefreshTimer); + } + resetWalletBalance(); + }; + }, [ + canAccessProtectedData, + currentUserId, + onWalletBalanceMayHaveChanged, + resetWalletBalance, + setWalletOwner, + ]); +} diff --git a/vitest.config.ts b/vitest.config.ts index 71b67da98..9ee1ef4ba 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -47,6 +47,9 @@ export default defineConfig({ 'src/services/image-editor/**/*.test.ts', 'src/services/external-generation/**/*.test.ts', 'src/services/payment/**/*.test.ts', + 'src/services/platform-entry/platformProfileClient.test.ts', + 'src/stores/**/*.test.ts', + 'src/stores/**/*.test.tsx', 'src/components/auth/**/*.test.ts', 'src/components/auth/**/*.test.tsx', 'src/components/creation-home/**/*.test.ts', @@ -72,7 +75,7 @@ export default defineConfig({ 'src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx', 'src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx', 'src/components/platform-entry/platformProfile*.test.ts', - 'src/components/platform-entry/usePlatformProfileCenterController.test.tsx', + 'src/components/platform-entry/usePlatformProfileCenterController*.test.tsx', 'src/hooks/useHostNavigationCanGoBack.test.tsx', 'apps/admin-web/src/**/*.test.ts', 'apps/admin-web/src/**/*.test.tsx', @@ -82,6 +85,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: [