合并最新 master 并解决画布共享冲突
同步 origin/master 的场景、音效、运行时与后端原子提交改动 保留共享画布包导出并补齐场景、生成配方和历史动作类型 融合清单刷新测试、幂等请求、生成合同校验与项目记忆文档
This commit is contained in:
@@ -6,3 +6,38 @@ export type BackgroundMusicPromptAssistResponse = {
|
||||
prompt: string;
|
||||
charCount: number;
|
||||
};
|
||||
|
||||
export type SoundEffectPromptOptimizeRequest = {
|
||||
currentPrompt: string;
|
||||
};
|
||||
|
||||
export type SoundEffectPromptOptimizeResponse = {
|
||||
prompt: string;
|
||||
charCount: number;
|
||||
};
|
||||
|
||||
export const EDITOR_SOUND_EFFECT_MODEL = 'eleven_text_to_sound_v2' as const;
|
||||
export const SOUND_EFFECT_DURATION_MIN_SECONDS = 0.5;
|
||||
export const SOUND_EFFECT_DURATION_MAX_SECONDS = 30;
|
||||
|
||||
export type EditorSoundEffectModel = typeof EDITOR_SOUND_EFFECT_MODEL;
|
||||
|
||||
export type EditorSoundEffectDurationMode = 'auto' | 'manual';
|
||||
|
||||
export type EditorSoundEffectGenerationRequest = {
|
||||
prompt: string;
|
||||
model: EditorSoundEffectModel;
|
||||
duration?: number | null;
|
||||
loop?: boolean;
|
||||
};
|
||||
|
||||
export type EditorSoundEffectGenerationMetadataV2 = {
|
||||
schemaVersion: 2;
|
||||
userPrompt: string;
|
||||
actualPrompt: string;
|
||||
model: EditorSoundEffectModel;
|
||||
durationMode: EditorSoundEffectDurationMode;
|
||||
requestedDurationSeconds: number | null;
|
||||
actualDurationSeconds: number;
|
||||
loop: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
export const EDITOR_SCENE_STYLE_PRESETS = {
|
||||
ANIME: {
|
||||
value: 'anime',
|
||||
label: '日系动画',
|
||||
description: '细腻光影、动画感构图',
|
||||
},
|
||||
WATERCOLOR: {
|
||||
value: 'watercolor',
|
||||
label: '清透水彩',
|
||||
description: '通透纸感、柔和自然晕染',
|
||||
},
|
||||
FLAT: {
|
||||
value: 'flat',
|
||||
label: '平面几何',
|
||||
description: '清晰色块、简化形体与轮廓',
|
||||
},
|
||||
STOP_MOTION: {
|
||||
value: 'stop-motion',
|
||||
label: '定格模型',
|
||||
description: '实体模型质感、微缩布景光影',
|
||||
},
|
||||
CUSTOM: {
|
||||
value: 'custom',
|
||||
label: '自定义',
|
||||
description: '填写具体画风',
|
||||
},
|
||||
} as const;
|
||||
|
||||
type EditorSceneStylePresetDefinition =
|
||||
(typeof EDITOR_SCENE_STYLE_PRESETS)[keyof typeof EDITOR_SCENE_STYLE_PRESETS];
|
||||
|
||||
export type EditorSceneStylePreset = EditorSceneStylePresetDefinition['value'];
|
||||
|
||||
export const EDITOR_SCENE_STYLE_PRESET_OPTIONS = Object.values(
|
||||
EDITOR_SCENE_STYLE_PRESETS,
|
||||
);
|
||||
export const DEFAULT_EDITOR_SCENE_STYLE_PRESET =
|
||||
EDITOR_SCENE_STYLE_PRESETS.ANIME.value;
|
||||
export const CUSTOM_EDITOR_SCENE_STYLE_PRESET =
|
||||
EDITOR_SCENE_STYLE_PRESETS.CUSTOM.value;
|
||||
|
||||
export function isEditorSceneStylePreset(
|
||||
value: unknown,
|
||||
): value is EditorSceneStylePreset {
|
||||
return EDITOR_SCENE_STYLE_PRESET_OPTIONS.some(
|
||||
(preset) => preset.value === value,
|
||||
);
|
||||
}
|
||||
|
||||
export function getEditorSceneStylePresetLabel(
|
||||
value: EditorSceneStylePreset,
|
||||
) {
|
||||
return (
|
||||
EDITOR_SCENE_STYLE_PRESET_OPTIONS.find(
|
||||
(preset) => preset.value === value,
|
||||
)?.label ?? EDITOR_SCENE_STYLE_PRESETS.ANIME.label
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveEditorSceneStylePresetByLabel(
|
||||
label: string | null | undefined,
|
||||
): EditorSceneStylePreset {
|
||||
return (
|
||||
EDITOR_SCENE_STYLE_PRESET_OPTIONS.find(
|
||||
(preset) => preset.label === label,
|
||||
)?.value ?? DEFAULT_EDITOR_SCENE_STYLE_PRESET
|
||||
);
|
||||
}
|
||||
|
||||
export type EditorSceneGenerationPlaceholder = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
};
|
||||
|
||||
export type EditorSceneGenerationCompletion = {
|
||||
dialogId?: string;
|
||||
title: string;
|
||||
placeholder: EditorSceneGenerationPlaceholder;
|
||||
};
|
||||
|
||||
export type EditorSceneGenerationInputField = {
|
||||
title: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type EditorSceneGenerationInputReference = {
|
||||
title: string;
|
||||
label: string;
|
||||
refType: 'project-resource' | 'asset';
|
||||
refId: string;
|
||||
};
|
||||
|
||||
export type EditorSceneGenerationInputs = {
|
||||
fields: EditorSceneGenerationInputField[];
|
||||
references: EditorSceneGenerationInputReference[];
|
||||
};
|
||||
|
||||
export type EditorSceneGenerationRequest = {
|
||||
sceneContent: string;
|
||||
stylePreset: EditorSceneStylePreset;
|
||||
customStyle?: string | null;
|
||||
model?: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
referenceImageSrcs?: string[];
|
||||
projectId?: string | null;
|
||||
generationInputs?: EditorSceneGenerationInputs | null;
|
||||
assetFolderId?: string | null;
|
||||
assetLabel?: string | null;
|
||||
canvasCompletion?: EditorSceneGenerationCompletion | null;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ export type * from './contracts/creationAudio';
|
||||
export type * from './contracts/creativeAgent';
|
||||
export type * from './contracts/customWorldAgent';
|
||||
export type * from './contracts/editorAudio';
|
||||
export * from './contracts/editorScene';
|
||||
export * from './contracts/edutainmentBabyDrawing';
|
||||
export * from './contracts/edutainmentBabyObject';
|
||||
export * from './contracts/externalGeneration';
|
||||
@@ -51,6 +52,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 });
|
||||
},
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user