收口根认证钱包生命周期

将主站唯一钱包 lifecycle 上移到 AuthGate 根认证边界

在账号切换、StrictMode 重放和根卸载时统一清空钱包 Store

移除平台子壳的重复 owner 声明并补充测试边界

新增 mount、focus、unmount 与 StrictMode 回归测试

同步钱包 lifecycle 长期决策与 Vitest 收集范围
This commit is contained in:
2026-08-07 11:23:18 +08:00
parent 098009a701
commit dee257c16c
8 changed files with 181 additions and 14 deletions
@@ -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`
+53 -1
View File
@@ -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<string, string>();
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(
<StrictMode>
<AuthGate>
<LogoutStateProbe />
</AuthGate>
</StrictMode>,
);
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: [],
+6
View File
@@ -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 (
<div
@@ -3,16 +3,19 @@
import {
act,
fireEvent,
render,
render as testingLibraryRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { useState } from 'react';
import { type ReactElement, type ReactNode, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import {
usePlatformWalletLifecycle,
usePlatformWalletStore,
} from '../../stores/usePlatformWalletStore';
import { PlatformEntryFlowShellImpl } from './PlatformEntryActiveFlowShell';
import type { SelectionStage } from './platformEntryActiveTypes';
@@ -141,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,
}: {
@@ -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,
);
@@ -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 }) => (
<StrictMode>{children}</StrictMode>
),
},
);
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,
);
});
+9 -2
View File
@@ -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,
]);
}
+2
View File
@@ -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',