修复泥点消耗刷新不及时 (#138)
refactor: - 主站复用game agent的zustand store fix: - 修复旧请求覆盖问题 - 修复账号切换问题 - 主站原本已经每 4 秒轮询 external generation 任务状态, 在这里补充触发余额刷新的时机 --------- Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/138 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #138.
This commit is contained in:
@@ -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: [],
|
||||
|
||||
@@ -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<AuthLoginMethod>([
|
||||
...REQUIRED_LOGIN_METHODS,
|
||||
...normalizedMethods,
|
||||
]),
|
||||
new Set<AuthLoginMethod>([...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 (
|
||||
<div
|
||||
@@ -969,7 +969,11 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const registrationInviteCode =
|
||||
pendingInviteCode || readInviteCodeFromLocation();
|
||||
const response = registrationInviteCode
|
||||
? await loginWithPhoneCode(phone, code, registrationInviteCode)
|
||||
? await loginWithPhoneCode(
|
||||
phone,
|
||||
code,
|
||||
registrationInviteCode,
|
||||
)
|
||||
: await loginWithPhoneCode(phone, code);
|
||||
const autoRedeemedInvite = response.referral?.ok === true;
|
||||
setStoredLastLoginPhone(phone);
|
||||
|
||||
@@ -994,9 +994,11 @@ describe('CreationLandingView', () => {
|
||||
'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)}`,
|
||||
|
||||
@@ -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(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
@@ -614,12 +631,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
);
|
||||
|
||||
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(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: 'U001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****0000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
})}
|
||||
>
|
||||
<ImageCanvasEditorView />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: 'U001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****0000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
})}
|
||||
>
|
||||
<ImageCanvasEditorView />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
loadEditorProject,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
|
||||
@@ -137,6 +137,7 @@ const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
|
||||
];
|
||||
|
||||
type ImageCanvasEditorViewProps = {
|
||||
legacyWalletBalance?: number | null;
|
||||
onProjectAccessLost?: () => 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<number | null>(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<HTMLElement | null>(null);
|
||||
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const assetListRef = useRef<HTMLDivElement | null>(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,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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<typeof listExternalGenerationTasks>[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(
|
||||
<ImageCanvasTaskSidebarView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusExternalTask={vi.fn()}
|
||||
onExternalTasksCompleted={onExternalTasksCompleted}
|
||||
onExternalTaskWalletMayHaveChanged={
|
||||
onExternalTaskWalletMayHaveChanged
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
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<typeof listExternalGenerationTasks>[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(
|
||||
<ImageCanvasTaskSidebarView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusExternalTask={vi.fn()}
|
||||
onExternalTaskWalletMayHaveChanged={
|
||||
onExternalTaskWalletMayHaveChanged
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
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<typeof listExternalGenerationTasks>[0] = {},
|
||||
): ReturnType<typeof listExternalGenerationTasks> => {
|
||||
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(
|
||||
<ImageCanvasTaskSidebarView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusExternalTask={vi.fn()}
|
||||
onExternalTaskWalletMayHaveChanged={
|
||||
onExternalTaskWalletMayHaveChanged
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
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<typeof listExternalGenerationTasks>[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(
|
||||
<ImageCanvasTaskSidebarView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusExternalTask={vi.fn()}
|
||||
onExternalTaskWalletMayHaveChanged={
|
||||
onExternalTaskWalletMayHaveChanged
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ImageCanvasTaskSidebarView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusExternalTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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<typeof listExternalGenerationTasks>[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(
|
||||
<ImageCanvasTaskSidebarView
|
||||
refreshKey={0}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusExternalTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByText('旧生成任务')).toBeTruthy();
|
||||
|
||||
listExternalGenerationTasksMock.mockRejectedValue(
|
||||
new Error('task list unavailable'),
|
||||
);
|
||||
rerender(
|
||||
<ImageCanvasTaskSidebarView
|
||||
refreshKey={1}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusExternalTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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<typeof listExternalGenerationTasks>[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<ReturnType<typeof listExternalGenerationTasks>>
|
||||
>((_, reject) => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => reject(new DOMException('Aborted', 'AbortError')),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = render(
|
||||
<ImageCanvasTaskSidebarView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusExternalTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
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(
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
<ProfileStatCard
|
||||
cardKey="wallet"
|
||||
label="泥点余额"
|
||||
value={
|
||||
dashboard
|
||||
? formatDashboardCount(dashboard.walletBalance)
|
||||
: '暂不可用'
|
||||
}
|
||||
value={formatWalletBalance(
|
||||
mudPointBalance,
|
||||
legacyWalletBalance,
|
||||
isLoadingWalletBalance,
|
||||
)}
|
||||
icon={Coins}
|
||||
imageSrc={profilePointImage}
|
||||
onClick={onOpenWalletLedger}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render as testingLibraryRender,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/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 {
|
||||
usePlatformWalletLifecycle,
|
||||
usePlatformWalletStore,
|
||||
} from '../../stores/usePlatformWalletStore';
|
||||
import { PlatformEntryFlowShellImpl } from './PlatformEntryActiveFlowShell';
|
||||
import type { SelectionStage } from './platformEntryActiveTypes';
|
||||
|
||||
const authUiMock = vi.hoisted(() => ({
|
||||
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: () => <main aria-label="图片画布编辑器" />,
|
||||
ImageCanvasEditorView: ({
|
||||
legacyWalletBalance,
|
||||
}: {
|
||||
legacyWalletBalance?: number | null;
|
||||
}) => (
|
||||
<main
|
||||
aria-label="图片画布编辑器"
|
||||
data-legacy-wallet-balance={legacyWalletBalance ?? ''}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
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(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="creation-home"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('泥点 207')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="profile"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="profile"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '泥点余额 37' }),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByText('37泥点')).toBeTruthy();
|
||||
expect(screen.getByText('暂无账单记录')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="image-editor"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="creation-home"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="creation-home"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="creation-home"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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 () => {
|
||||
|
||||
@@ -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({
|
||||
<div className="image-editor-stage-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<Suspense fallback={<LoadingPanel label="正在加载编辑器..." />}>
|
||||
<ImageCanvasEditorView
|
||||
legacyWalletBalance={legacyWalletBalance}
|
||||
onProjectAccessLost={replaceWithProjectGallery}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -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({
|
||||
<PlatformMudPointWalletEntry
|
||||
variant={isDesktopLayout ? 'desktop' : 'mobile'}
|
||||
balance={balance}
|
||||
breakdown={
|
||||
profileCenter.rechargeCenter?.mudPointBalance ?? null
|
||||
}
|
||||
isLoading={
|
||||
isLoadingDashboard ||
|
||||
profileCenter.isLoadingRechargeCenter
|
||||
}
|
||||
error={profileCenter.rechargeError}
|
||||
breakdown={mudPointBalance}
|
||||
isLoading={isWalletBalanceLoading}
|
||||
error={mudPointBalanceError || null}
|
||||
className={
|
||||
isDesktopLayout
|
||||
? 'platform-desktop-create-wallet-chip'
|
||||
@@ -515,6 +543,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
<PlatformActiveProfileView
|
||||
dashboard={dashboard}
|
||||
isLoadingDashboard={isLoadingDashboard}
|
||||
isLoadingWalletBalance={isWalletBalanceLoading}
|
||||
legacyWalletBalance={legacyWalletBalance}
|
||||
mudPointBalance={mudPointBalance}
|
||||
user={authUi?.user}
|
||||
onLogin={() => authUi?.openLoginModal()}
|
||||
onOpenApiKeys={() => setIsApiKeysOpen(true)}
|
||||
|
||||
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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<ReturnType<typeof nativeRechargeResponse>>();
|
||||
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<typeof nativeRechargeResponse>['order'];
|
||||
center: ReturnType<typeof nativeRechargeResponse>['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<ReturnType<typeof nativeRechargeResponse>>();
|
||||
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<typeof nativeRechargeResponse>['order'];
|
||||
center: ReturnType<typeof nativeRechargeResponse>['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<never>();
|
||||
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),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<AuthUser, 'id'>;
|
||||
|
||||
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 } },
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+33
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>)
|
||||
.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<ReturnType<typeof createResponseMock>>();
|
||||
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<ReturnType<typeof createResponseMock>>();
|
||||
const accountBResponse =
|
||||
createDeferred<ReturnType<typeof createResponseMock>>();
|
||||
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<ReturnType<typeof createResponseMock>>();
|
||||
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<typeof createResponseMock>
|
||||
>();
|
||||
const refreshResponse =
|
||||
createDeferred<ReturnType<typeof createResponseMock>>();
|
||||
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、防火墙或代理配置',
|
||||
|
||||
+105
-15
@@ -114,7 +114,9 @@ function normalizeHeaders(headers?: HeadersInit) {
|
||||
|
||||
function hasHeader(headers: Record<string, string>, 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<string, string>) {
|
||||
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<string, string>, options: ApiRequestOptions) {
|
||||
function resolveRequestIdHeader(
|
||||
headers: Record<string, string>,
|
||||
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<string> | null = null;
|
||||
type AuthStateSnapshot = {
|
||||
generation: number;
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
type RefreshAccessTokenAttempt = AuthStateSnapshot & {
|
||||
promise: Promise<string>;
|
||||
};
|
||||
|
||||
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,
|
||||
|
||||
@@ -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) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user