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

为钱包快照绑定请求发起时的 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
@@ -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 });