修复泥点消耗刷新不及时 (#138)
Project CI / Repository checks (push) Successful in 50s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

refactor:
- 主站复用game agent的zustand store

fix:
- 修复旧请求覆盖问题
- 修复账号切换问题

- 主站原本已经每 4 秒轮询 external generation 任务状态, 在这里补充触发余额刷新的时机

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/138
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #138.
This commit is contained in:
2026-08-08 10:25:02 +08:00
committed by 段舒康
parent 44de26d204
commit fc0dcb0782
36 changed files with 4160 additions and 491 deletions
+1
View File
@@ -51,6 +51,7 @@ export type * from './contracts/visualNovel';
export * from './http';
export * from './llm/narrativeLanguage';
export * from './llm/parsers';
export * from './stores/createProfileWalletStore';
export * from './utils/signedReadUrlCache';
// should not export component, instead, they should be ref by relative path
@@ -0,0 +1,337 @@
import { describe, expect, test, vi } from 'vitest';
import type {
ProfileMudPointBalance,
ProfileRechargeCenterResponse,
} from '../contracts/runtime';
import { createProfileWalletStore } from './createProfileWalletStore';
function balance(totalPoints: number): ProfileMudPointBalance {
return {
totalPoints,
permanentPoints: totalPoints,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 0,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
};
}
function center(totalPoints: number): ProfileRechargeCenterResponse {
return {
mudPointBalance: balance(totalPoints),
} as ProfileRechargeCenterResponse;
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
describe('createProfileWalletStore', () => {
test('reads the first complete wallet snapshot for its owner', async () => {
const getRechargeCenter = vi.fn().mockResolvedValue(center(42));
const store = createProfileWalletStore({ getRechargeCenter });
store.getState().setWalletOwner('user-a');
await store.getState().onWalletBalanceMayHaveChanged();
expect(getRechargeCenter).toHaveBeenCalledTimes(1);
expect(store.getState()).toMatchObject({
ownerUserId: 'user-a',
mudPointBalance: balance(42),
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});
});
test('merges concurrent notifications into one request', async () => {
const pending = deferred<ProfileRechargeCenterResponse>();
const getRechargeCenter = vi.fn(() => pending.promise);
const store = createProfileWalletStore({ getRechargeCenter });
store.getState().setWalletOwner('user-a');
const first = store.getState().onWalletBalanceMayHaveChanged();
const second = store.getState().onWalletBalanceMayHaveChanged();
expect(first).toBe(second);
expect(getRechargeCenter).toHaveBeenCalledTimes(1);
pending.resolve(center(12));
await first;
expect(getRechargeCenter).toHaveBeenCalledTimes(2);
});
test('reads again when balance changes during an active request', async () => {
const first = deferred<ProfileRechargeCenterResponse>();
const getRechargeCenter = vi
.fn()
.mockImplementationOnce(() => first.promise)
.mockResolvedValueOnce(center(30));
const store = createProfileWalletStore({ getRechargeCenter });
store.getState().setWalletOwner('user-a');
const refresh = store.getState().onWalletBalanceMayHaveChanged();
void store.getState().onWalletBalanceMayHaveChanged();
first.resolve(center(99));
await refresh;
expect(getRechargeCenter).toHaveBeenCalledTimes(2);
expect(store.getState().mudPointBalance?.totalPoints).toBe(30);
});
test('does not let an older read overwrite an applied mutation snapshot', async () => {
const pending = deferred<ProfileRechargeCenterResponse>();
const store = createProfileWalletStore({
getRechargeCenter: vi.fn(() => pending.promise),
});
store.getState().setWalletOwner('user-a');
const refresh = store.getState().onWalletBalanceMayHaveChanged();
const snapshot = store.getState().captureWalletBalanceSnapshot('user-a');
expect(snapshot).not.toBeNull();
store.getState().applyWalletBalanceSnapshot(snapshot!, balance(80));
pending.resolve(center(20));
await refresh;
expect(store.getState().mudPointBalance?.totalPoints).toBe(80);
});
test('refreshes the new owner without waiting for a stalled old-owner request', async () => {
const oldOwner = deferred<ProfileRechargeCenterResponse>();
const newOwner = deferred<ProfileRechargeCenterResponse>();
const requestSignals: Array<AbortSignal | undefined> = [];
const getRechargeCenter = vi
.fn()
.mockImplementationOnce((signal?: AbortSignal) => {
requestSignals.push(signal);
return oldOwner.promise;
})
.mockImplementationOnce((signal?: AbortSignal) => {
requestSignals.push(signal);
return newOwner.promise;
});
const store = createProfileWalletStore({ getRechargeCenter });
store.getState().setWalletOwner('user-a');
const snapshot = store.getState().captureWalletBalanceSnapshot('user-a');
store.getState().applyWalletBalanceSnapshot(snapshot!, balance(50));
const oldRefresh = store.getState().onWalletBalanceMayHaveChanged();
store.getState().setWalletOwner('user-b');
expect(store.getState()).toMatchObject({
ownerUserId: 'user-b',
mudPointBalance: null,
mudPointBalanceStatus: 'idle',
});
const newRefresh = store.getState().onWalletBalanceMayHaveChanged();
expect(getRechargeCenter).toHaveBeenCalledTimes(2);
expect(newRefresh).not.toBe(oldRefresh);
expect(requestSignals[0]?.aborted).toBe(true);
expect(requestSignals[1]?.aborted).toBe(false);
newOwner.resolve(center(7));
await newRefresh;
expect(store.getState().mudPointBalance?.totalPoints).toBe(7);
oldOwner.resolve(center(60));
await oldRefresh;
expect(store.getState().mudPointBalance?.totalPoints).toBe(7);
});
test('keeps an existing snapshot when refresh fails', async () => {
const store = createProfileWalletStore({
getRechargeCenter: vi.fn().mockRejectedValue(new Error('network down')),
});
store.getState().setWalletOwner('user-a');
const snapshot = store.getState().captureWalletBalanceSnapshot('user-a');
store.getState().applyWalletBalanceSnapshot(snapshot!, balance(55));
await store.getState().onWalletBalanceMayHaveChanged();
expect(store.getState()).toMatchObject({
mudPointBalance: balance(55),
mudPointBalanceStatus: 'error',
mudPointBalanceError: '泥点明细读取失败',
});
});
test('treats a recharge response without the balance breakdown as an error', async () => {
const store = createProfileWalletStore({
getRechargeCenter: vi
.fn()
.mockResolvedValue({} as ProfileRechargeCenterResponse),
});
store.getState().setWalletOwner('user-a');
await store.getState().onWalletBalanceMayHaveChanged();
expect(store.getState()).toMatchObject({
mudPointBalance: null,
mudPointBalanceStatus: 'error',
mudPointBalanceError: '泥点明细读取失败',
});
});
test('does not capture a snapshot for a different owner', () => {
const store = createProfileWalletStore({
getRechargeCenter: vi.fn(),
});
store.getState().setWalletOwner('user-b');
const snapshot = store.getState().captureWalletBalanceSnapshot('user-a');
expect(snapshot).toBeNull();
expect(store.getState().mudPointBalance).toBeNull();
expect(store.getState().mudPointBalanceStatus).toBe('idle');
});
test('normalizes a snapshot owner before matching it', () => {
const store = createProfileWalletStore({
getRechargeCenter: vi.fn(),
});
store.getState().setWalletOwner('user-a');
const snapshot = store
.getState()
.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 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('clears a stale balance breakdown when a newer refresh only has a legacy total', async () => {
const getRechargeCenter = vi
.fn()
.mockResolvedValueOnce(center(90))
.mockResolvedValueOnce({
walletBalance: 37,
} as ProfileRechargeCenterResponse);
const store = createProfileWalletStore({ getRechargeCenter });
store.getState().setWalletOwner('user-a');
await store.getState().onWalletBalanceMayHaveChanged();
await store.getState().onWalletBalanceMayHaveChanged();
expect(store.getState()).toMatchObject({
legacyWalletBalance: 37,
mudPointBalance: null,
mudPointBalanceStatus: 'error',
mudPointBalanceError: '泥点明细读取失败',
});
});
test('applies an owner-scoped legacy mutation snapshot atomically', () => {
const store = createProfileWalletStore({ getRechargeCenter: vi.fn() });
store.getState().setWalletOwner('user-a');
const structuredSnapshot = store
.getState()
.captureWalletBalanceSnapshot('user-a');
store
.getState()
.applyWalletBalanceSnapshot(structuredSnapshot!, balance(90));
const legacySnapshot = store
.getState()
.captureWalletBalanceSnapshot('user-a');
expect(
store.getState().applyLegacyWalletBalanceSnapshot(legacySnapshot!, 37),
).toBe(true);
expect(store.getState()).toMatchObject({
legacyWalletBalance: 37,
mudPointBalance: null,
mudPointBalanceStatus: 'ready',
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');
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();
});
});
@@ -0,0 +1,238 @@
import { create, type StoreApi, type UseBoundStore } from 'zustand';
import type {
ProfileMudPointBalance,
ProfileRechargeCenterResponse,
} from '../contracts/runtime';
export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error';
export type ProfileWalletApi = {
getRechargeCenter(
signal?: AbortSignal,
): Promise<ProfileRechargeCenterResponse>;
};
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;
captureWalletBalanceSnapshot: (
ownerUserId: string,
) => ProfileWalletBalanceSnapshot | null;
applyWalletBalanceSnapshot: (
snapshot: ProfileWalletBalanceSnapshot,
balance: ProfileMudPointBalance,
) => boolean;
applyLegacyWalletBalanceSnapshot: (
snapshot: ProfileWalletBalanceSnapshot,
balance: number,
) => boolean;
onWalletBalanceMayHaveChanged: () => Promise<void>;
resetWalletBalance: () => void;
};
const EMPTY_WALLET_STATE = {
mudPointBalance: null,
legacyWalletBalance: null,
mudPointBalanceStatus: 'idle',
mudPointBalanceError: '',
} as const;
export function createProfileWalletStore(
api: ProfileWalletApi,
): UseBoundStore<StoreApi<ProfileWalletStore>> {
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;
const invalidateActiveRefresh = () => {
requestGeneration += 1;
activeAbortController?.abort();
activeAbortController = null;
activeRefresh = null;
};
return create<ProfileWalletStore>((set, get) => ({
ownerUserId: null,
...EMPTY_WALLET_STATE,
setWalletOwner: (userId) => {
const normalizedUserId = userId?.trim() || null;
if (get().ownerUserId === normalizedUserId) {
return;
}
ownerVersion += 1;
latestAppliedOperationSequence = 0;
invalidateActiveRefresh();
settledRefreshVersion = refreshVersion;
set({
ownerUserId: normalizedUserId,
...EMPTY_WALLET_STATE,
});
},
captureWalletBalanceSnapshot: (ownerUserId) => {
const normalizedOwnerUserId = ownerUserId.trim();
if (
!normalizedOwnerUserId ||
get().ownerUserId !== normalizedOwnerUserId
) {
return null;
}
return {
ownerUserId: normalizedOwnerUserId,
ownerVersion,
invalidationVersion: refreshVersion,
operationSequence: ++operationSequence,
};
},
applyWalletBalanceSnapshot: (snapshot, balance) => {
if (
get().ownerUserId !== snapshot.ownerUserId ||
ownerVersion !== snapshot.ownerVersion ||
snapshot.invalidationVersion < refreshVersion ||
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: '',
});
return true;
},
applyLegacyWalletBalanceSnapshot: (snapshot, balance) => {
if (
!Number.isFinite(balance) ||
get().ownerUserId !== snapshot.ownerUserId ||
ownerVersion !== snapshot.ownerVersion ||
snapshot.invalidationVersion < refreshVersion ||
snapshot.invalidationVersion < settledRefreshVersion ||
snapshot.operationSequence < latestAppliedOperationSequence
) {
return false;
}
invalidateActiveRefresh();
latestAppliedOperationSequence = Math.max(
latestAppliedOperationSequence,
snapshot.operationSequence,
);
settledRefreshVersion = snapshot.invalidationVersion;
set({
mudPointBalance: null,
legacyWalletBalance: balance,
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});
return true;
},
onWalletBalanceMayHaveChanged: () => {
refreshVersion += 1;
if (activeRefresh) {
return activeRefresh;
}
const abortController = new AbortController();
const refresh = (async () => {
while (settledRefreshVersion < refreshVersion) {
const ownerUserId = get().ownerUserId;
const requestedVersion = refreshVersion;
const generation = requestGeneration;
if (!ownerUserId) {
settledRefreshVersion = requestedVersion;
return;
}
set({ mudPointBalanceStatus: 'loading', mudPointBalanceError: '' });
try {
const center = await api.getRechargeCenter(abortController.signal);
if (generation !== requestGeneration) {
return;
}
if (requestedVersion !== refreshVersion) {
settledRefreshVersion = requestedVersion;
continue;
}
if (!center.mudPointBalance) {
if (Number.isFinite(center.walletBalance)) {
set({
mudPointBalance: null,
legacyWalletBalance: center.walletBalance,
});
}
throw new Error('充值中心响应缺少泥点余额');
}
settledRefreshVersion = requestedVersion;
set({
mudPointBalance: center.mudPointBalance,
legacyWalletBalance: center.mudPointBalance.totalPoints,
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});
} catch {
if (generation !== requestGeneration) {
return;
}
if (requestedVersion !== refreshVersion) {
settledRefreshVersion = requestedVersion;
continue;
}
settledRefreshVersion = requestedVersion;
set({
mudPointBalanceStatus: 'error',
mudPointBalanceError: '泥点明细读取失败',
});
}
}
})();
activeRefresh = refresh;
activeAbortController = abortController;
const clearActiveRefresh = () => {
if (activeRefresh === refresh) {
activeRefresh = null;
}
if (activeAbortController === abortController) {
activeAbortController = null;
}
};
void refresh.then(clearActiveRefresh, clearActiveRefresh);
return refresh;
},
resetWalletBalance: () => {
ownerVersion += 1;
latestAppliedOperationSequence = 0;
invalidateActiveRefresh();
settledRefreshVersion = refreshVersion;
set({ ownerUserId: null, ...EMPTY_WALLET_STATE });
},
}));
}