修复钱包异步边界与任务陈旧状态

隔离账号切换后的充值响应并中止失效支付监听

防止乱序钱包快照回滚并保留旧版总额兜底

清理任务重试耗尽后的陈旧状态并补充回归测试

稳定测试夹具、用户错误提示与首帧加载状态

同步单一钱包 Store 的长期决策记录
This commit is contained in:
2026-08-07 11:00:04 +08:00
parent 58bf56ec56
commit 098009a701
12 changed files with 273 additions and 65 deletions
@@ -715,6 +715,66 @@ describe('ImageCanvasTaskSidebarView', () => {
}
});
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 {
@@ -413,10 +413,12 @@ export function ImageCanvasTaskSidebarView({
if (controller === attemptController) {
controller = null;
}
if (
disposed ||
retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length
) {
if (disposed) {
return;
}
if (retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length) {
setExternalTasks([]);
setWalletActiveExternalTaskIds([]);
return;
}
const retryDelayMs = TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS[retryIndex];
@@ -223,7 +223,7 @@ describe('PlatformEntryActiveFlowShell', () => {
expect(screen.queryByText('999')).toBeNull();
});
it('passes the owner-matched legacy total to profile and editor while keeping the ledger empty', async () => {
it('passes the lifecycle legacy total to profile and editor without opening recharge', async () => {
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
@@ -240,7 +240,6 @@ describe('PlatformEntryActiveFlowShell', () => {
walletBalance: 37,
});
profileCenterMock.isWalletLedgerOpen = true;
profileCenterMock.rechargeCenter = { walletBalance: 37 };
const { rerender } = render(
<PlatformEntryFlowShellImpl
@@ -240,6 +240,9 @@ export function PlatformEntryFlowShellImpl({
const storedMudPointBalance = usePlatformWalletStore(
(state) => state.mudPointBalance,
);
const storedLegacyWalletBalance = usePlatformWalletStore(
(state) => state.legacyWalletBalance,
);
const storedMudPointBalanceStatus = usePlatformWalletStore(
(state) => state.mudPointBalanceStatus,
);
@@ -252,12 +255,14 @@ export function PlatformEntryFlowShellImpl({
const mudPointBalance = walletOwnerMatchesCurrentUser
? storedMudPointBalance
: null;
const mudPointBalanceStatus = walletOwnerMatchesCurrentUser
? storedMudPointBalanceStatus
: 'idle';
const mudPointBalanceError = walletOwnerMatchesCurrentUser
? storedMudPointBalanceError
: '';
const isWalletBalanceLoading =
Boolean(currentWalletOwnerUserId) &&
(!walletOwnerMatchesCurrentUser ||
storedMudPointBalanceStatus === 'idle' ||
storedMudPointBalanceStatus === 'loading');
const refreshDashboard = useCallback(async () => {
if (!authUi?.user || !authUi.canAccessProtectedData) {
@@ -288,7 +293,9 @@ export function PlatformEntryFlowShellImpl({
currentUser: authUi?.user,
});
const legacyWalletBalance = walletOwnerMatchesCurrentUser
? (profileCenter.rechargeCenter?.walletBalance ?? null)
? (storedLegacyWalletBalance ??
profileCenter.rechargeCenter?.walletBalance ??
null)
: null;
const openCreation = useCallback(() => {
@@ -489,7 +496,7 @@ export function PlatformEntryFlowShellImpl({
variant={isDesktopLayout ? 'desktop' : 'mobile'}
balance={balance}
breakdown={mudPointBalance}
isLoading={mudPointBalanceStatus === 'loading'}
isLoading={isWalletBalanceLoading}
error={mudPointBalanceError || null}
className={
isDesktopLayout
@@ -543,7 +550,7 @@ export function PlatformEntryFlowShellImpl({
<PlatformActiveProfileView
dashboard={dashboard}
isLoadingDashboard={isLoadingDashboard}
isLoadingWalletBalance={mudPointBalanceStatus === 'loading'}
isLoadingWalletBalance={isWalletBalanceLoading}
legacyWalletBalance={legacyWalletBalance}
mudPointBalance={mudPointBalance}
user={authUi?.user}
@@ -65,13 +65,28 @@ export {
import { usePlatformProfileCenterController } from './usePlatformProfileCenterController';
export const userA = { id: 'user-a' } as AuthUser;
export const userB = { id: 'user-b' } as AuthUser;
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({
@@ -79,7 +94,7 @@ export function renderController(
isAuthenticated: true,
showRechargeEntry: true,
onRechargeSuccess,
requestLogin: vi.fn(),
requestLogin,
currentUser: user,
}),
{ initialProps: { user: currentUser } },
@@ -61,11 +61,7 @@ type WechatPayResult = {
};
type RechargePaymentResultKind =
| 'success'
| 'pending'
| 'cancel'
| 'failed'
| 'expired';
'success' | 'pending' | 'cancel' | 'failed' | 'expired';
export type RechargePaymentResult = {
kind: RechargePaymentResultKind;
@@ -971,22 +967,23 @@ export function usePlatformProfileCenterController({
}, [openRechargeModal, openRewardCodeModal, showRechargeEntry]);
const closeNativeWechatPayment = useCallback(() => {
setNativeWechatPayment((current) => {
if (current?.isConfirming) {
return current;
}
const pendingOrder = pendingWechatRechargeOrderRef.current;
if (
current &&
pendingOrder?.orderId === current.orderId &&
isRechargeOrderCurrent(pendingOrder)
) {
pendingOrder.abortController.abort();
pendingWechatRechargeOrderRef.current = null;
}
return null;
});
}, [isRechargeOrderCurrent]);
if (!nativeWechatPayment || nativeWechatPayment.isConfirming) {
return;
}
const pendingOrder = pendingWechatRechargeOrderRef.current;
if (
pendingOrder?.orderId === nativeWechatPayment.orderId &&
isRechargeOrderCurrent(pendingOrder)
) {
pendingOrder.abortController.abort();
pendingWechatRechargeOrderRef.current = null;
}
setNativeWechatPayment((current) =>
current?.orderId === nativeWechatPayment.orderId && !current.isConfirming
? null
: current,
);
}, [isRechargeOrderCurrent, nativeWechatPayment]);
const buyRechargeProduct = useCallback(
(product: ProfileRechargeProduct) => {
@@ -1363,7 +1360,24 @@ export function usePlatformProfileCenterController({
}
let cancelled = false;
const confirmationOptions = createRechargeConfirmationOptions(order);
const orderConfirmationOptions = createRechargeConfirmationOptions(order);
const effectAbortController = new AbortController();
const abortEffectRequest = () => {
effectAbortController.abort(orderConfirmationOptions.signal.reason);
};
if (orderConfirmationOptions.signal.aborted) {
abortEffectRequest();
} else {
orderConfirmationOptions.signal.addEventListener(
'abort',
abortEffectRequest,
{ once: true },
);
}
const confirmationOptions = {
...orderConfirmationOptions,
signal: effectAbortController.signal,
};
const watchUntilSettled = async () => {
while (
!cancelled &&
@@ -1441,6 +1455,11 @@ export function usePlatformProfileCenterController({
void watchUntilSettled();
return () => {
cancelled = true;
orderConfirmationOptions.signal.removeEventListener(
'abort',
abortEffectRequest,
);
effectAbortController.abort();
};
}, [
nativeWechatPayment?.expiresAt,