修复钱包异步边界与任务陈旧状态
隔离账号切换后的充值响应并中止失效支付监听 防止乱序钱包快照回滚并保留旧版总额兜底 清理任务重试耗尽后的陈旧状态并补充回归测试 稳定测试夹具、用户错误提示与首帧加载状态 同步单一钱包 Store 的长期决策记录
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user