修复钱包异步边界与任务陈旧状态
隔离账号切换后的充值响应并中止失效支付监听 防止乱序钱包快照回滚并保留旧版总额兜底 清理任务重试耗尽后的陈旧状态并补充回归测试 稳定测试夹具、用户错误提示与首帧加载状态 同步单一钱包 Store 的长期决策记录
This commit is contained in:
@@ -11,7 +11,10 @@ export function AccountWalletBar({
|
||||
return (
|
||||
<div className="launcher-account-bar" aria-label="账户资产">
|
||||
<PlatformMudPointWalletEntry
|
||||
balance={controller.mudPointBalance?.totalPoints ?? null}
|
||||
balance={
|
||||
controller.mudPointBalance?.totalPoints ??
|
||||
controller.legacyWalletBalance
|
||||
}
|
||||
breakdown={controller.mudPointBalance}
|
||||
isLoading={controller.mudPointBalanceStatus === 'loading'}
|
||||
error={controller.mudPointBalanceError || null}
|
||||
@@ -49,7 +52,11 @@ export function AccountWalletDialogs({
|
||||
{controller.walletLedgerOpen ? (
|
||||
<PlatformProfileWalletLedgerModal
|
||||
ledger={controller.walletLedger}
|
||||
fallbackBalance={controller.mudPointBalance?.totalPoints ?? 0}
|
||||
fallbackBalance={
|
||||
controller.mudPointBalance?.totalPoints ??
|
||||
controller.legacyWalletBalance ??
|
||||
0
|
||||
}
|
||||
isLoading={controller.walletLedgerLoading}
|
||||
error={controller.walletLedgerError}
|
||||
onClose={() => controller.setWalletLedgerOpen(false)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { PlatformProfileRechargeNativePaymentState } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal';
|
||||
import type {
|
||||
@@ -19,6 +19,7 @@ export function useAccountWallet(currentUserId: string) {
|
||||
const {
|
||||
ownerUserId,
|
||||
mudPointBalance,
|
||||
legacyWalletBalance,
|
||||
mudPointBalanceStatus,
|
||||
mudPointBalanceError,
|
||||
setWalletOwner,
|
||||
@@ -47,7 +48,6 @@ export function useAccountWallet(currentUserId: string) {
|
||||
const walletLedgerLifecycleRef = useRef(0);
|
||||
const [walletUiOwnerUserId, setWalletUiOwnerUserId] = useState(currentUserId);
|
||||
const currentUserIdRef = useRef(currentUserId);
|
||||
currentUserIdRef.current = currentUserId;
|
||||
const walletOwnerMatchesCurrentUser =
|
||||
Boolean(currentUserId) && ownerUserId === currentUserId;
|
||||
const walletUiOwnerMatchesCurrentUser =
|
||||
@@ -57,12 +57,23 @@ export function useAccountWallet(currentUserId: string) {
|
||||
const visibleMudPointBalance = walletOwnerMatchesCurrentUser
|
||||
? mudPointBalance
|
||||
: null;
|
||||
const visibleLegacyWalletBalance = walletOwnerMatchesCurrentUser
|
||||
? legacyWalletBalance
|
||||
: null;
|
||||
const visibleMudPointBalanceStatus = walletOwnerMatchesCurrentUser
|
||||
? mudPointBalanceStatus
|
||||
: 'idle';
|
||||
const visibleMudPointBalanceError = walletOwnerMatchesCurrentUser
|
||||
? mudPointBalanceError
|
||||
: '';
|
||||
const visibleWalletBalance =
|
||||
visibleMudPointBalance?.totalPoints ?? visibleLegacyWalletBalance;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
currentUserIdRef.current = currentUserId;
|
||||
rechargeLifecycleRef.current += 1;
|
||||
walletLedgerLifecycleRef.current += 1;
|
||||
}, [currentUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
setWalletOwner(currentUserId || null);
|
||||
@@ -82,8 +93,6 @@ export function useAccountWallet(currentUserId: string) {
|
||||
}, [currentUserId, onWalletBalanceMayHaveChanged, setWalletOwner]);
|
||||
|
||||
useEffect(() => {
|
||||
rechargeLifecycleRef.current += 1;
|
||||
walletLedgerLifecycleRef.current += 1;
|
||||
setWalletUiOwnerUserId(currentUserId);
|
||||
setWalletLedgerOpen(false);
|
||||
setWalletLedger(null);
|
||||
@@ -162,18 +171,27 @@ export function useAccountWallet(currentUserId: string) {
|
||||
setRechargeError(null);
|
||||
try {
|
||||
const center = await getClientProfileRechargeCenter();
|
||||
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
|
||||
if (
|
||||
rechargeLifecycleRef.current !== rechargeLifecycle ||
|
||||
currentUserIdRef.current !== snapshotOwnerUserId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
applyRechargeContent(walletSnapshot, center);
|
||||
} catch (error) {
|
||||
if (rechargeLifecycleRef.current === rechargeLifecycle) {
|
||||
if (
|
||||
rechargeLifecycleRef.current === rechargeLifecycle &&
|
||||
currentUserIdRef.current === snapshotOwnerUserId
|
||||
) {
|
||||
setRechargeError(
|
||||
error instanceof Error ? error.message : '读取泥点购买信息失败',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (rechargeLifecycleRef.current === rechargeLifecycle) {
|
||||
if (
|
||||
rechargeLifecycleRef.current === rechargeLifecycle &&
|
||||
currentUserIdRef.current === snapshotOwnerUserId
|
||||
) {
|
||||
setRechargeLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -198,14 +216,18 @@ export function useAccountWallet(currentUserId: string) {
|
||||
return;
|
||||
}
|
||||
const rechargeLifecycle = rechargeLifecycleRef.current;
|
||||
const walletSnapshot = captureWalletBalanceSnapshot(currentUserId);
|
||||
const snapshotOwnerUserId = currentUserId;
|
||||
const walletSnapshot = captureWalletBalanceSnapshot(snapshotOwnerUserId);
|
||||
setSubmittingRechargeProductId(product.productId);
|
||||
setRechargeError(null);
|
||||
try {
|
||||
const response = await createClientProfileRechargeOrder(
|
||||
product.productId,
|
||||
);
|
||||
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
|
||||
if (
|
||||
rechargeLifecycleRef.current !== rechargeLifecycle ||
|
||||
currentUserIdRef.current !== snapshotOwnerUserId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
applyRechargeContent(walletSnapshot, response.center);
|
||||
@@ -224,11 +246,17 @@ export function useAccountWallet(currentUserId: string) {
|
||||
isConfirming: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (rechargeLifecycleRef.current === rechargeLifecycle) {
|
||||
if (
|
||||
rechargeLifecycleRef.current === rechargeLifecycle &&
|
||||
currentUserIdRef.current === snapshotOwnerUserId
|
||||
) {
|
||||
setRechargeError(error instanceof Error ? error.message : '充值失败');
|
||||
}
|
||||
} finally {
|
||||
if (rechargeLifecycleRef.current === rechargeLifecycle) {
|
||||
if (
|
||||
rechargeLifecycleRef.current === rechargeLifecycle &&
|
||||
currentUserIdRef.current === snapshotOwnerUserId
|
||||
) {
|
||||
setSubmittingRechargeProductId(null);
|
||||
}
|
||||
}
|
||||
@@ -239,7 +267,8 @@ export function useAccountWallet(currentUserId: string) {
|
||||
return;
|
||||
}
|
||||
const rechargeLifecycle = rechargeLifecycleRef.current;
|
||||
const walletSnapshot = captureWalletBalanceSnapshot(currentUserId);
|
||||
const snapshotOwnerUserId = currentUserId;
|
||||
const walletSnapshot = captureWalletBalanceSnapshot(snapshotOwnerUserId);
|
||||
const orderId = nativeRechargePayment.orderId;
|
||||
setNativeRechargePayment((current) =>
|
||||
current?.orderId === orderId
|
||||
@@ -248,7 +277,10 @@ export function useAccountWallet(currentUserId: string) {
|
||||
);
|
||||
try {
|
||||
const response = await confirmClientWechatProfileRechargeOrder(orderId);
|
||||
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
|
||||
if (
|
||||
rechargeLifecycleRef.current !== rechargeLifecycle ||
|
||||
currentUserIdRef.current !== snapshotOwnerUserId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
applyRechargeContent(walletSnapshot, response.center);
|
||||
@@ -269,7 +301,10 @@ export function useAccountWallet(currentUserId: string) {
|
||||
: current,
|
||||
);
|
||||
} catch {
|
||||
if (rechargeLifecycleRef.current === rechargeLifecycle) {
|
||||
if (
|
||||
rechargeLifecycleRef.current === rechargeLifecycle &&
|
||||
currentUserIdRef.current === snapshotOwnerUserId
|
||||
) {
|
||||
setNativeRechargePayment((current) =>
|
||||
current?.orderId === orderId
|
||||
? {
|
||||
@@ -286,6 +321,7 @@ export function useAccountWallet(currentUserId: string) {
|
||||
return {
|
||||
ownerUserId,
|
||||
mudPointBalance: visibleMudPointBalance,
|
||||
legacyWalletBalance: visibleLegacyWalletBalance,
|
||||
mudPointBalanceStatus: visibleMudPointBalanceStatus,
|
||||
mudPointBalanceError: visibleMudPointBalanceError,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
@@ -297,11 +333,14 @@ export function useAccountWallet(currentUserId: string) {
|
||||
walletLedgerError: walletUiIsVisible ? walletLedgerError : null,
|
||||
rechargeOpen: walletUiIsVisible && rechargeOpen,
|
||||
rechargeModalCenter:
|
||||
rechargeContent && visibleMudPointBalance
|
||||
rechargeContent &&
|
||||
visibleWalletBalance !== null
|
||||
? {
|
||||
...rechargeContent,
|
||||
walletBalance: visibleMudPointBalance.totalPoints,
|
||||
mudPointBalance: visibleMudPointBalance,
|
||||
walletBalance: visibleWalletBalance,
|
||||
...(visibleMudPointBalance
|
||||
? { mudPointBalance: visibleMudPointBalance }
|
||||
: {}),
|
||||
}
|
||||
: null,
|
||||
rechargeLoading: walletUiIsVisible && rechargeLoading,
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('useWalletStore', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('keeps mudPointBalance as the only balance state', async () => {
|
||||
test('keeps mudPointBalance as the authoritative detailed balance state', async () => {
|
||||
const mudPointBalance = detailedBalance(120);
|
||||
clientApi.getClientProfileRechargeCenter.mockResolvedValue({
|
||||
walletBalance: 120,
|
||||
@@ -49,11 +49,13 @@ describe('useWalletStore', () => {
|
||||
});
|
||||
|
||||
expect(result.current.mudPointBalance).toEqual(mudPointBalance);
|
||||
expect(result.current.legacyWalletBalance).toBe(120);
|
||||
expect(result.current.mudPointBalanceStatus).toBe('ready');
|
||||
expect(result.current.mudPointBalanceError).toBe('');
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'applyWalletBalanceSnapshot',
|
||||
'captureWalletBalanceSnapshot',
|
||||
'legacyWalletBalance',
|
||||
'mudPointBalance',
|
||||
'mudPointBalanceError',
|
||||
'mudPointBalanceStatus',
|
||||
@@ -116,9 +118,10 @@ describe('useWalletStore', () => {
|
||||
await useWalletStore.getState().onWalletBalanceMayHaveChanged();
|
||||
|
||||
expect(useWalletStore.getState().mudPointBalance).toBeNull();
|
||||
expect(useWalletStore.getState().legacyWalletBalance).toBe(120);
|
||||
expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error');
|
||||
expect(useWalletStore.getState().mudPointBalanceError).toBe(
|
||||
'充值中心响应缺少泥点余额',
|
||||
'泥点明细读取失败',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -178,7 +181,9 @@ describe('useWalletStore', () => {
|
||||
|
||||
expect(useWalletStore.getState().mudPointBalance?.totalPoints).toBe(90);
|
||||
expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error');
|
||||
expect(useWalletStore.getState().mudPointBalanceError).toBe('刷新失败');
|
||||
expect(useWalletStore.getState().mudPointBalanceError).toBe(
|
||||
'泥点明细读取失败',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not restore a late response after reset', async () => {
|
||||
|
||||
@@ -6480,6 +6480,7 @@
|
||||
- UI 与 mutation:充值 controller 继续保存商品、订单和支付状态,但充值中心响应必须同步写入共享快照,余额 mutation 应在应用响应后再通知一次合并刷新。充值弹窗的余额和分桶由当前 Store 快照覆盖。生成完成、失败退款、兑换码和邀请奖励等事件只发送余额可能变化通知;账号变化同时清理旧充值中心、账单与支付临时状态。`limitedPoints` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。
|
||||
- 2026-08-05 审查补充:主站 transport adapter 必须通过既有请求 options 真实透传 `AbortSignal`;页面恢复时相邻的 `visibilitychange / focus` 合并为一次余额通知,并在卸载时清理待执行任务。Store 的 owner 输入统一在边界 trim,活动请求清理同时观察 Promise 成功与失败,不能用无人接收的 `finally` 派生 Promise。
|
||||
- 2026-08-05 账号隔离补充,2026-08-07 完善 legacy 总额入口:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`;owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。
|
||||
- 2026-08-07 审查收口补充:共享 Store 显式保存 owner 隔离的 `legacyWalletBalance`,确保首次生命周期读取旧响应时无需先打开充值弹窗即可展示纯总额;该字段不能生成 `mudPointBalance` 分桶。直接余额快照附带单调 operation sequence,后发操作先落地后拒绝更早快照回滚。刷新错误只向 UI 暴露稳定中文提示,不透传 transport / 后端实现文案。
|
||||
- 2026-08-05 生成扣退费时序补充:external generation 入队时尚未扣费,worker 领取为 `running` 后的资产操作才预扣,业务失败则先退款再写任务失败态。主站钱包因此以账号下全局 active external task 为轮询生命周期,每轮成功状态读取都通知共享 Store 合并刷新,终态轮同时覆盖成功结算与失败退款;画布内容刷新仍只限当前项目的 `completed`,不因其它项目或 `failed` 刷新画布。
|
||||
- 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。
|
||||
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('createProfileWalletStore', () => {
|
||||
expect(store.getState()).toMatchObject({
|
||||
mudPointBalance: balance(55),
|
||||
mudPointBalanceStatus: 'error',
|
||||
mudPointBalanceError: 'network down',
|
||||
mudPointBalanceError: '泥点明细读取失败',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('createProfileWalletStore', () => {
|
||||
expect(store.getState()).toMatchObject({
|
||||
mudPointBalance: null,
|
||||
mudPointBalanceStatus: 'error',
|
||||
mudPointBalanceError: '充值中心响应缺少泥点余额',
|
||||
mudPointBalanceError: '泥点明细读取失败',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -235,6 +235,43 @@ describe('createProfileWalletStore', () => {
|
||||
expect(store.getState().mudPointBalance?.totalPoints).toBe(100);
|
||||
});
|
||||
|
||||
test('rejects an older direct snapshot after a newer operation has applied', () => {
|
||||
const store = createProfileWalletStore({ getRechargeCenter: vi.fn() });
|
||||
store.getState().setWalletOwner('user-a');
|
||||
const olderSnapshot = store
|
||||
.getState()
|
||||
.captureWalletBalanceSnapshot('user-a');
|
||||
const newerSnapshot = store
|
||||
.getState()
|
||||
.captureWalletBalanceSnapshot('user-a');
|
||||
|
||||
expect(
|
||||
store.getState().applyWalletBalanceSnapshot(newerSnapshot!, balance(100)),
|
||||
).toBe(true);
|
||||
expect(
|
||||
store.getState().applyWalletBalanceSnapshot(olderSnapshot!, balance(20)),
|
||||
).toBe(false);
|
||||
expect(store.getState().mudPointBalance?.totalPoints).toBe(100);
|
||||
});
|
||||
|
||||
test('retains a legacy total without inventing a balance breakdown', async () => {
|
||||
const store = createProfileWalletStore({
|
||||
getRechargeCenter: vi.fn().mockResolvedValue({
|
||||
walletBalance: 37,
|
||||
} as ProfileRechargeCenterResponse),
|
||||
});
|
||||
store.getState().setWalletOwner('user-a');
|
||||
|
||||
await store.getState().onWalletBalanceMayHaveChanged();
|
||||
|
||||
expect(store.getState()).toMatchObject({
|
||||
legacyWalletBalance: 37,
|
||||
mudPointBalance: null,
|
||||
mudPointBalanceStatus: 'error',
|
||||
mudPointBalanceError: '泥点明细读取失败',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a snapshot after switching away from and back to the same owner', () => {
|
||||
const store = createProfileWalletStore({ getRechargeCenter: vi.fn() });
|
||||
store.getState().setWalletOwner('user-a');
|
||||
|
||||
@@ -17,11 +17,13 @@ export type ProfileWalletBalanceSnapshot = Readonly<{
|
||||
ownerUserId: string;
|
||||
ownerVersion: number;
|
||||
invalidationVersion: number;
|
||||
operationSequence: number;
|
||||
}>;
|
||||
|
||||
export type ProfileWalletStore = {
|
||||
ownerUserId: string | null;
|
||||
mudPointBalance: ProfileMudPointBalance | null;
|
||||
legacyWalletBalance: number | null;
|
||||
mudPointBalanceStatus: MudPointBalanceStatus;
|
||||
mudPointBalanceError: string;
|
||||
setWalletOwner: (userId: string | null) => void;
|
||||
@@ -38,6 +40,7 @@ export type ProfileWalletStore = {
|
||||
|
||||
const EMPTY_WALLET_STATE = {
|
||||
mudPointBalance: null,
|
||||
legacyWalletBalance: null,
|
||||
mudPointBalanceStatus: 'idle',
|
||||
mudPointBalanceError: '',
|
||||
} as const;
|
||||
@@ -48,6 +51,8 @@ export function createProfileWalletStore(
|
||||
let refreshVersion = 0;
|
||||
let settledRefreshVersion = 0;
|
||||
let ownerVersion = 0;
|
||||
let operationSequence = 0;
|
||||
let latestAppliedOperationSequence = 0;
|
||||
let requestGeneration = 0;
|
||||
let activeRefresh: Promise<void> | null = null;
|
||||
let activeAbortController: AbortController | null = null;
|
||||
@@ -69,6 +74,7 @@ export function createProfileWalletStore(
|
||||
}
|
||||
|
||||
ownerVersion += 1;
|
||||
latestAppliedOperationSequence = 0;
|
||||
invalidateActiveRefresh();
|
||||
settledRefreshVersion = refreshVersion;
|
||||
set({
|
||||
@@ -89,6 +95,7 @@ export function createProfileWalletStore(
|
||||
ownerUserId: normalizedOwnerUserId,
|
||||
ownerVersion,
|
||||
invalidationVersion: refreshVersion,
|
||||
operationSequence: ++operationSequence,
|
||||
};
|
||||
},
|
||||
applyWalletBalanceSnapshot: (snapshot, balance) => {
|
||||
@@ -96,15 +103,21 @@ export function createProfileWalletStore(
|
||||
get().ownerUserId !== snapshot.ownerUserId ||
|
||||
ownerVersion !== snapshot.ownerVersion ||
|
||||
snapshot.invalidationVersion < refreshVersion ||
|
||||
snapshot.invalidationVersion < settledRefreshVersion
|
||||
snapshot.invalidationVersion < settledRefreshVersion ||
|
||||
snapshot.operationSequence < latestAppliedOperationSequence
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
invalidateActiveRefresh();
|
||||
latestAppliedOperationSequence = Math.max(
|
||||
latestAppliedOperationSequence,
|
||||
snapshot.operationSequence,
|
||||
);
|
||||
settledRefreshVersion = snapshot.invalidationVersion;
|
||||
set({
|
||||
mudPointBalance: balance,
|
||||
legacyWalletBalance: balance.totalPoints,
|
||||
mudPointBalanceStatus: 'ready',
|
||||
mudPointBalanceError: '',
|
||||
});
|
||||
@@ -138,16 +151,20 @@ export function createProfileWalletStore(
|
||||
continue;
|
||||
}
|
||||
if (!center.mudPointBalance) {
|
||||
if (Number.isFinite(center.walletBalance)) {
|
||||
set({ legacyWalletBalance: center.walletBalance });
|
||||
}
|
||||
throw new Error('充值中心响应缺少泥点余额');
|
||||
}
|
||||
|
||||
settledRefreshVersion = requestedVersion;
|
||||
set({
|
||||
mudPointBalance: center.mudPointBalance,
|
||||
legacyWalletBalance: center.mudPointBalance.totalPoints,
|
||||
mudPointBalanceStatus: 'ready',
|
||||
mudPointBalanceError: '',
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (generation !== requestGeneration) {
|
||||
return;
|
||||
}
|
||||
@@ -159,8 +176,7 @@ export function createProfileWalletStore(
|
||||
settledRefreshVersion = requestedVersion;
|
||||
set({
|
||||
mudPointBalanceStatus: 'error',
|
||||
mudPointBalanceError:
|
||||
error instanceof Error ? error.message : '泥点明细读取失败',
|
||||
mudPointBalanceError: '泥点明细读取失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -180,6 +196,7 @@ export function createProfileWalletStore(
|
||||
},
|
||||
resetWalletBalance: () => {
|
||||
ownerVersion += 1;
|
||||
latestAppliedOperationSequence = 0;
|
||||
invalidateActiveRefresh();
|
||||
settledRefreshVersion = refreshVersion;
|
||||
set({ ownerUserId: null, ...EMPTY_WALLET_STATE });
|
||||
|
||||
@@ -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