修复钱包旧快照吞掉终态刷新

为钱包快照绑定请求发起时的 owner 生命周期与 invalidation 版本
拒绝过期普通 GET 快照覆盖余额或中止更新刷新
同步主站与 AI Game Creator 调用方及竞态回归测试
更新账户与充值基线约束
This commit is contained in:
2026-08-06 10:35:25 +08:00
parent 6300ba55f7
commit 2bb52ef915
7 changed files with 176 additions and 60 deletions
@@ -22,6 +22,7 @@ export function useAccountWallet(currentUserId: string) {
mudPointBalanceStatus,
mudPointBalanceError,
setWalletOwner,
captureWalletBalanceSnapshot,
applyWalletBalanceSnapshot,
onWalletBalanceMayHaveChanged,
resetWalletBalance,
@@ -136,7 +137,7 @@ export function useAccountWallet(currentUserId: string) {
}
function applyRechargeContent(
snapshotOwnerUserId: string,
walletSnapshot: ReturnType<typeof captureWalletBalanceSnapshot>,
center: ProfileRechargeCenterResponse,
) {
const {
@@ -145,8 +146,8 @@ export function useAccountWallet(currentUserId: string) {
...content
} = center;
void walletBalance;
if (nextMudPointBalance) {
applyWalletBalanceSnapshot(snapshotOwnerUserId, nextMudPointBalance);
if (nextMudPointBalance && walletSnapshot) {
applyWalletBalanceSnapshot(walletSnapshot, nextMudPointBalance);
} else {
void onWalletBalanceMayHaveChanged();
}
@@ -155,6 +156,7 @@ export function useAccountWallet(currentUserId: string) {
async function loadRechargeCenter() {
const snapshotOwnerUserId = currentUserId;
const walletSnapshot = captureWalletBalanceSnapshot(snapshotOwnerUserId);
const rechargeLifecycle = rechargeLifecycleRef.current;
setRechargeLoading(true);
setRechargeError(null);
@@ -163,7 +165,7 @@ export function useAccountWallet(currentUserId: string) {
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
return;
}
applyRechargeContent(snapshotOwnerUserId, center);
applyRechargeContent(walletSnapshot, center);
} catch (error) {
if (rechargeLifecycleRef.current === rechargeLifecycle) {
setRechargeError(
@@ -307,9 +309,7 @@ export function useAccountWallet(currentUserId: string) {
submittingRechargeProductId: walletUiIsVisible
? submittingRechargeProductId
: null,
nativeRechargePayment: walletUiIsVisible
? nativeRechargePayment
: null,
nativeRechargePayment: walletUiIsVisible ? nativeRechargePayment : null,
setNativeRechargePayment,
loadWalletLedger,
openWalletLedger,
@@ -53,6 +53,7 @@ describe('useWalletStore', () => {
expect(result.current.mudPointBalanceError).toBe('');
expect(Object.keys(result.current).sort()).toEqual([
'applyWalletBalanceSnapshot',
'captureWalletBalanceSnapshot',
'mudPointBalance',
'mudPointBalanceError',
'mudPointBalanceStatus',
@@ -70,9 +71,12 @@ describe('useWalletStore', () => {
await useWalletStore.getState().onWalletBalanceMayHaveChanged();
const mudPointBalance = detailedBalance(120);
const snapshot = useWalletStore
.getState()
.captureWalletBalanceSnapshot('user-a');
useWalletStore
.getState()
.applyWalletBalanceSnapshot('user-a', mudPointBalance);
.applyWalletBalanceSnapshot(snapshot!, mudPointBalance);
expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance);
expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready');
@@ -90,9 +94,12 @@ describe('useWalletStore', () => {
const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged();
const mudPointBalance = detailedBalance(160);
const snapshot = useWalletStore
.getState()
.captureWalletBalanceSnapshot('user-a');
useWalletStore
.getState()
.applyWalletBalanceSnapshot('user-a', mudPointBalance);
.applyWalletBalanceSnapshot(snapshot!, mudPointBalance);
rejectRequest?.(new Error('过期请求失败'));
await refresh;
@@ -141,10 +148,12 @@ describe('useWalletStore', () => {
mudPointBalance: detailedBalance(80),
});
const firstRefresh =
useWalletStore.getState().onWalletBalanceMayHaveChanged();
const trailingRefresh =
useWalletStore.getState().onWalletBalanceMayHaveChanged();
const firstRefresh = useWalletStore
.getState()
.onWalletBalanceMayHaveChanged();
const trailingRefresh = useWalletStore
.getState()
.onWalletBalanceMayHaveChanged();
resolveFirst?.({
walletBalance: 120,
mudPointBalance: detailedBalance(120),
@@ -194,9 +203,12 @@ describe('useWalletStore', () => {
});
test('clears immediately when the adapter switches wallet owners', () => {
const snapshot = useWalletStore
.getState()
.captureWalletBalanceSnapshot('user-a');
useWalletStore
.getState()
.applyWalletBalanceSnapshot('user-a', detailedBalance(88));
.applyWalletBalanceSnapshot(snapshot!, detailedBalance(88));
useWalletStore.getState().setWalletOwner('user-b');
@@ -208,9 +220,12 @@ describe('useWalletStore', () => {
});
test('masks another owner snapshot before the owner-binding effect runs', () => {
const snapshot = useWalletStore
.getState()
.captureWalletBalanceSnapshot('user-a');
useWalletStore
.getState()
.applyWalletBalanceSnapshot('user-a', detailedBalance(88));
.applyWalletBalanceSnapshot(snapshot!, detailedBalance(88));
function WalletProbe() {
const wallet = useAccountWallet('user-b');
@@ -59,7 +59,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
## 账户与充值
1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。
1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。普通充值中心 GET 必须在发起时捕获钱包 owner 生命周期与 invalidation 版本;回包只能结算不晚于该版本的刷新,过期快照不得覆盖余额或中止更新的终态刷新。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。
2. 账户充值弹窗标题统一为“购买更多泥点”,当前版本只展示泥点商品,不展示会员页签、会员商品、购买会员或升级会员入口。底层会员数据与周期刷新能力继续保留用于存量兼容和结算,会员周期限时泥点不在当前版本前台展示。
3. 泥点默认商品固定为四档:`60 泥点 / ¥6``180 + 90 泥点 / ¥18``300 + 150 泥点 / ¥30``680 + 340 泥点 / ¥68``60` 档不加赠,后三档首次购买各加赠基础泥点的 `50%`;实际展示、下单校验和支付确认仍以后端返回的充值商品配置为准。
4. 首充加赠资格按泥点商品档位独立计算。用户买过 `points_180` 后,只影响 `points_180` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。
@@ -19,7 +19,9 @@ function balance(totalPoints: number): ProfileMudPointBalance {
}
function center(totalPoints: number): ProfileRechargeCenterResponse {
return { mudPointBalance: balance(totalPoints) } as ProfileRechargeCenterResponse;
return {
mudPointBalance: balance(totalPoints),
} as ProfileRechargeCenterResponse;
}
function deferred<T>() {
@@ -91,7 +93,9 @@ describe('createProfileWalletStore', () => {
store.getState().setWalletOwner('user-a');
const refresh = store.getState().onWalletBalanceMayHaveChanged();
store.getState().applyWalletBalanceSnapshot('user-a', balance(80));
const snapshot = store.getState().captureWalletBalanceSnapshot('user-a');
expect(snapshot).not.toBeNull();
store.getState().applyWalletBalanceSnapshot(snapshot!, balance(80));
pending.resolve(center(20));
await refresh;
@@ -114,7 +118,8 @@ describe('createProfileWalletStore', () => {
});
const store = createProfileWalletStore({ getRechargeCenter });
store.getState().setWalletOwner('user-a');
store.getState().applyWalletBalanceSnapshot('user-a', balance(50));
const snapshot = store.getState().captureWalletBalanceSnapshot('user-a');
store.getState().applyWalletBalanceSnapshot(snapshot!, balance(50));
const oldRefresh = store.getState().onWalletBalanceMayHaveChanged();
store.getState().setWalletOwner('user-b');
@@ -143,7 +148,8 @@ describe('createProfileWalletStore', () => {
getRechargeCenter: vi.fn().mockRejectedValue(new Error('network down')),
});
store.getState().setWalletOwner('user-a');
store.getState().applyWalletBalanceSnapshot('user-a', balance(55));
const snapshot = store.getState().captureWalletBalanceSnapshot('user-a');
store.getState().applyWalletBalanceSnapshot(snapshot!, balance(55));
await store.getState().onWalletBalanceMayHaveChanged();
@@ -171,14 +177,15 @@ describe('createProfileWalletStore', () => {
});
});
test('ignores a snapshot captured for a different owner', () => {
test('does not capture a snapshot for a different owner', () => {
const store = createProfileWalletStore({
getRechargeCenter: vi.fn(),
});
store.getState().setWalletOwner('user-b');
store.getState().applyWalletBalanceSnapshot('user-a', balance(100));
const snapshot = store.getState().captureWalletBalanceSnapshot('user-a');
expect(snapshot).toBeNull();
expect(store.getState().mudPointBalance).toBeNull();
expect(store.getState().mudPointBalanceStatus).toBe('idle');
});
@@ -189,13 +196,60 @@ describe('createProfileWalletStore', () => {
});
store.getState().setWalletOwner('user-a');
store
const snapshot = store
.getState()
.applyWalletBalanceSnapshot(' user-a ', balance(100));
.captureWalletBalanceSnapshot(' user-a ');
store.getState().applyWalletBalanceSnapshot(snapshot!, balance(100));
expect(store.getState()).toMatchObject({
mudPointBalance: balance(100),
mudPointBalanceStatus: 'ready',
});
});
test('does not let an older ordinary snapshot swallow a terminal refresh', async () => {
const terminalRefresh = deferred<ProfileRechargeCenterResponse>();
const requestSignals: Array<AbortSignal | undefined> = [];
const store = createProfileWalletStore({
getRechargeCenter: vi.fn((signal?: AbortSignal) => {
requestSignals.push(signal);
return terminalRefresh.promise;
}),
});
store.getState().setWalletOwner('user-a');
const ordinarySnapshot = store
.getState()
.captureWalletBalanceSnapshot('user-a');
const refresh = store.getState().onWalletBalanceMayHaveChanged();
expect(
store
.getState()
.applyWalletBalanceSnapshot(ordinarySnapshot!, balance(80)),
).toBe(false);
expect(requestSignals[0]?.aborted).toBe(false);
terminalRefresh.resolve(center(100));
await refresh;
expect(store.getState().mudPointBalance?.totalPoints).toBe(100);
});
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');
const oldLifecycleSnapshot = store
.getState()
.captureWalletBalanceSnapshot('user-a');
store.getState().setWalletOwner('user-b');
store.getState().setWalletOwner('user-a');
expect(
store
.getState()
.applyWalletBalanceSnapshot(oldLifecycleSnapshot!, balance(100)),
).toBe(false);
expect(store.getState().mudPointBalance).toBeNull();
});
});
@@ -8,19 +8,30 @@ import type {
export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error';
export type ProfileWalletApi = {
getRechargeCenter(signal?: AbortSignal): Promise<ProfileRechargeCenterResponse>;
getRechargeCenter(
signal?: AbortSignal,
): Promise<ProfileRechargeCenterResponse>;
};
export type ProfileWalletBalanceSnapshot = Readonly<{
ownerUserId: string;
ownerVersion: number;
invalidationVersion: number;
}>;
export type ProfileWalletStore = {
ownerUserId: string | null;
mudPointBalance: ProfileMudPointBalance | null;
mudPointBalanceStatus: MudPointBalanceStatus;
mudPointBalanceError: string;
setWalletOwner: (userId: string | null) => void;
applyWalletBalanceSnapshot: (
captureWalletBalanceSnapshot: (
ownerUserId: string,
) => ProfileWalletBalanceSnapshot | null;
applyWalletBalanceSnapshot: (
snapshot: ProfileWalletBalanceSnapshot,
balance: ProfileMudPointBalance,
) => void;
) => boolean;
onWalletBalanceMayHaveChanged: () => Promise<void>;
resetWalletBalance: () => void;
};
@@ -36,6 +47,7 @@ export function createProfileWalletStore(
): UseBoundStore<StoreApi<ProfileWalletStore>> {
let refreshVersion = 0;
let settledRefreshVersion = 0;
let ownerVersion = 0;
let requestGeneration = 0;
let activeRefresh: Promise<void> | null = null;
let activeAbortController: AbortController | null = null;
@@ -56,6 +68,7 @@ export function createProfileWalletStore(
return;
}
ownerVersion += 1;
invalidateActiveRefresh();
settledRefreshVersion = refreshVersion;
set({
@@ -63,22 +76,39 @@ export function createProfileWalletStore(
...EMPTY_WALLET_STATE,
});
},
applyWalletBalanceSnapshot: (ownerUserId, balance) => {
captureWalletBalanceSnapshot: (ownerUserId) => {
const normalizedOwnerUserId = ownerUserId.trim();
if (
!normalizedOwnerUserId ||
get().ownerUserId !== normalizedOwnerUserId
) {
return;
return null;
}
return {
ownerUserId: normalizedOwnerUserId,
ownerVersion,
invalidationVersion: refreshVersion,
};
},
applyWalletBalanceSnapshot: (snapshot, balance) => {
if (
get().ownerUserId !== snapshot.ownerUserId ||
ownerVersion !== snapshot.ownerVersion ||
snapshot.invalidationVersion < refreshVersion ||
snapshot.invalidationVersion < settledRefreshVersion
) {
return false;
}
invalidateActiveRefresh();
settledRefreshVersion = refreshVersion;
settledRefreshVersion = snapshot.invalidationVersion;
set({
mudPointBalance: balance,
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});
return true;
},
onWalletBalanceMayHaveChanged: () => {
refreshVersion += 1;
@@ -149,6 +179,7 @@ export function createProfileWalletStore(
return refresh;
},
resetWalletBalance: () => {
ownerVersion += 1;
invalidateActiveRefresh();
settledRefreshVersion = refreshVersion;
set({ ownerUserId: null, ...EMPTY_WALLET_STATE });
@@ -596,15 +596,20 @@ describe('ImageCanvasEditorView', () => {
it('shows the live mud point balance in the canvas topbar when logged in', async () => {
usePlatformWalletStore.getState().setWalletOwner('user-1');
usePlatformWalletStore.getState().applyWalletBalanceSnapshot('user-1', {
totalPoints: 1234,
permanentPoints: 1000,
limitedPoints: 214,
limitedExpiresAt: '2026-07-31T16:00:00Z',
dailyFreePoints: 20,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
});
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({
@@ -668,15 +673,20 @@ describe('ImageCanvasEditorView', () => {
it('opens the shared wallet breakdown and ledger from the canvas topbar', async () => {
const user = userEvent.setup();
usePlatformWalletStore.getState().setWalletOwner('user-1');
usePlatformWalletStore.getState().applyWalletBalanceSnapshot('user-1', {
totalPoints: 1234,
permanentPoints: 1000,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 234,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
});
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({
@@ -61,11 +61,7 @@ type WechatPayResult = {
};
type RechargePaymentResultKind =
| 'success'
| 'pending'
| 'cancel'
| 'failed'
| 'expired';
'success' | 'pending' | 'cancel' | 'failed' | 'expired';
export type RechargePaymentResult = {
kind: RechargePaymentResultKind;
@@ -369,6 +365,9 @@ export function usePlatformProfileCenterController({
const applyWalletBalanceSnapshot = usePlatformWalletStore(
(state) => state.applyWalletBalanceSnapshot,
);
const captureWalletBalanceSnapshot = usePlatformWalletStore(
(state) => state.captureWalletBalanceSnapshot,
);
const onWalletBalanceMayHaveChanged = usePlatformWalletStore(
(state) => state.onWalletBalanceMayHaveChanged,
);
@@ -402,8 +401,9 @@ export function usePlatformProfileCenterController({
setRewardCodeSuccess(null);
setIsSubmittingReferralRedeem(false);
}, [currentUserId]);
const rechargeCenterReadAbortControllerRef =
useRef<AbortController | null>(null);
const rechargeCenterReadAbortControllerRef = useRef<AbortController | null>(
null,
);
useEffect(() => {
return () => {
@@ -480,7 +480,11 @@ export function usePlatformProfileCenterController({
}, [loadWalletLedger]);
const applyRechargeCenter = useCallback(
(center: ProfileRechargeCenterResponse, refreshWallet = false) => {
(
center: ProfileRechargeCenterResponse,
refreshWallet = false,
walletSnapshot = captureWalletBalanceSnapshot(currentUserId),
) => {
if (
!currentUserId ||
usePlatformWalletStore.getState().ownerUserId !== currentUserId
@@ -493,8 +497,8 @@ export function usePlatformProfileCenterController({
setIsLoadingRechargeCenter(false);
setRechargeError(null);
setRechargeCenter(center);
if (center.mudPointBalance) {
applyWalletBalanceSnapshot(currentUserId, center.mudPointBalance);
if (center.mudPointBalance && walletSnapshot) {
applyWalletBalanceSnapshot(walletSnapshot, center.mudPointBalance);
}
if (refreshWallet || !center.mudPointBalance) {
void onWalletBalanceMayHaveChanged();
@@ -503,12 +507,14 @@ export function usePlatformProfileCenterController({
},
[
applyWalletBalanceSnapshot,
captureWalletBalanceSnapshot,
currentUserId,
onWalletBalanceMayHaveChanged,
],
);
const loadRechargeCenter = useCallback(() => {
const walletSnapshot = captureWalletBalanceSnapshot(currentUserId);
const revision = ++rechargeCenterReadRevisionRef.current;
rechargeCenterReadAbortControllerRef.current?.abort();
const abortController = new AbortController();
@@ -521,7 +527,7 @@ export function usePlatformProfileCenterController({
!abortController.signal.aborted &&
revision === rechargeCenterReadRevisionRef.current
) {
applyRechargeCenter(center);
applyRechargeCenter(center, false, walletSnapshot);
}
})
.catch((error: unknown) => {
@@ -544,7 +550,7 @@ export function usePlatformProfileCenterController({
setIsLoadingRechargeCenter(false);
}
});
}, [applyRechargeCenter]);
}, [applyRechargeCenter, captureWalletBalanceSnapshot, currentUserId]);
const refreshRechargeState = useCallback(() => {
loadRechargeCenter();