From dee257c16c3668f73618c72390e3efb8f84a6013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 11:23:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E6=A0=B9=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E9=92=B1=E5=8C=85=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将主站唯一钱包 lifecycle 上移到 AuthGate 根认证边界 在账号切换、StrictMode 重放和根卸载时统一清空钱包 Store 移除平台子壳的重复 owner 声明并补充测试边界 新增 mount、focus、unmount 与 StrictMode 回归测试 同步钱包 lifecycle 长期决策与 Vitest 收集范围 --- .../shared-memory/decision-log.md | 1 + src/components/auth/AuthGate.test.tsx | 54 +++++++++++- src/components/auth/AuthGate.tsx | 6 ++ .../PlatformEntryActiveFlowShell.test.tsx | 27 +++++- .../PlatformEntryActiveFlowShell.tsx | 9 +- src/stores/usePlatformWalletStore.test.tsx | 85 +++++++++++++++++++ src/stores/usePlatformWalletStore.ts | 11 ++- vitest.config.ts | 2 + 8 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 src/stores/usePlatformWalletStore.test.tsx diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 100d19bba..cbc5f2283 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6481,6 +6481,7 @@ - 2026-08-05 审查补充:主站 transport adapter 必须通过既有请求 options 真实透传 `AbortSignal`;页面恢复时相邻的 `visibilitychange / focus` 合并为一次余额通知,并在卸载时清理待执行任务。Store 的 owner 输入统一在边界 trim,活动请求清理同时观察 Promise 成功与失败,不能用无人接收的 `finally` 派生 Promise。 - 2026-08-05 账号隔离补充,2026-08-07 完善 legacy 总额入口:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`;owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。 - 2026-08-07 审查收口补充:共享 Store 显式保存 owner 隔离的 `legacyWalletBalance`,确保首次生命周期读取旧响应时无需先打开充值弹窗即可展示纯总额;该字段不能生成 `mudPointBalance` 分桶。直接余额快照附带单调 operation sequence,后发操作先落地后拒绝更早快照回滚。刷新错误只向 UI 暴露稳定中文提示,不透传 transport / 后端实现文案。 +- 2026-08-07 lifecycle 所有权补充:主站钱包坚持全应用唯一 lifecycle,由 `AuthGate` 根认证边界绑定 ready user;现役平台壳、图片编辑器和 profile controller 只消费 Store,不重复声明 owner。根边界在账号失效、依赖切换、StrictMode effect replay 和最终卸载 cleanup 时统一 `resetWalletBalance`,中止活动请求并清除 owner、明细和 legacy 总额;禁止在子页面按相同 user ID 各自清理 module-level Store。 - 2026-08-05 生成扣退费时序补充:external generation 入队时尚未扣费,worker 领取为 `running` 后的资产操作才预扣,业务失败则先退款再写任务失败态。主站钱包因此以账号下全局 active external task 为轮询生命周期,每轮成功状态读取都通知共享 Store 合并刷新,终态轮同时覆盖成功结算与失败退款;画布内容刷新仍只限当前项目的 `completed`,不因其它项目或 `failed` 刷新画布。 - 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 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..e9cb49d95 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'; @@ -687,6 +688,11 @@ export function AuthGate({ children }: AuthGateProps) { ], ); + usePlatformWalletLifecycle( + readyUser?.id ?? null, + status === 'ready' && Boolean(readyUser), + ); + if (status === 'checking' && !canKeepPlatformContentMounted) { return (
({ }), })); +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, }: { diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 9eea741cc..32310a11d 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -25,10 +25,7 @@ import { replaceAppHistoryPath, } from '../../routing/activeAppPageRoutes'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; -import { - usePlatformWalletLifecycle, - usePlatformWalletStore, -} from '../../stores/usePlatformWalletStore'; +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; import { useAuthUi } from '../auth/AuthUiContext'; import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel'; import { PlatformActionButton } from '../common/PlatformActionButton'; @@ -230,10 +227,6 @@ export function PlatformEntryFlowShellImpl({ const isDesktopLayout = usePlatformDesktopLayout(); const currentWalletOwnerUserId = authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null; - usePlatformWalletLifecycle( - currentWalletOwnerUserId, - Boolean(authUi?.canAccessProtectedData), - ); const walletOwnerUserId = usePlatformWalletStore( (state) => state.ownerUserId, ); 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 index d4b40e868..6d6da60f4 100644 --- a/src/stores/usePlatformWalletStore.ts +++ b/src/stores/usePlatformWalletStore.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { createProfileWalletStore } from '@/packages/shared/src'; +import { createProfileWalletStore } from '@/packages/shared/src/stores/createProfileWalletStore'; import { getPlatformProfileRechargeCenter } from '@/src/services/platform-entry/platformProfileClient.ts'; export const usePlatformWalletStore = createProfileWalletStore({ @@ -17,13 +17,18 @@ export function usePlatformWalletLifecycle( const onWalletBalanceMayHaveChanged = usePlatformWalletStore( (state) => state.onWalletBalanceMayHaveChanged, ); + const resetWalletBalance = usePlatformWalletStore( + (state) => state.resetWalletBalance, + ); useEffect(() => { const ownerUserId = canAccessProtectedData && currentUserId ? currentUserId : null; setWalletOwner(ownerUserId); if (!ownerUserId) { - return undefined; + return () => { + resetWalletBalance(); + }; } void onWalletBalanceMayHaveChanged(); @@ -51,11 +56,13 @@ export function usePlatformWalletLifecycle( if (foregroundRefreshTimer !== null) { window.clearTimeout(foregroundRefreshTimer); } + resetWalletBalance(); }; }, [ canAccessProtectedData, currentUserId, onWalletBalanceMayHaveChanged, + resetWalletBalance, setWalletOwner, ]); } diff --git a/vitest.config.ts b/vitest.config.ts index f811c433b..9ee1ef4ba 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -48,6 +48,8 @@ export default defineConfig({ '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',