统一主站与AI游戏创作钱包状态
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Successful in 3m2s
Project CI / Native shell tests (pull_request) Failing after 5m29s

新增共享泥点钱包 Store 并合并并发刷新
  按账号 owner 隔离快照并屏蔽切换期间的旧余额
  中止并脱离旧账号请求以保障新账号立即刷新
  统一主站、图片编辑器和 AI Game Creator 钱包数据源
  补充账号切换、悬挂请求和焦点刷新回归测试
  同步更新项目基线与共享决策记录
This commit is contained in:
2026-08-03 19:56:38 +08:00
parent 9450377693
commit 3a4a8e2029
19 changed files with 993 additions and 255 deletions
@@ -17,9 +17,11 @@ import { useWalletStore } from '../../stores/useWalletStore';
export function useAccountWallet(currentUserId: string) {
const {
ownerUserId,
mudPointBalance,
mudPointBalanceStatus,
mudPointBalanceError,
setWalletOwner,
applyWalletBalanceSnapshot,
onWalletBalanceMayHaveChanged,
resetWalletBalance,
@@ -41,32 +43,70 @@ export function useAccountWallet(currentUserId: string) {
const [nativeRechargePayment, setNativeRechargePayment] =
useState<PlatformProfileRechargeNativePaymentState | null>(null);
const rechargeLifecycleRef = useRef(0);
const walletOwnerMatchesCurrentUser =
Boolean(currentUserId) && ownerUserId === currentUserId;
const visibleMudPointBalance = walletOwnerMatchesCurrentUser
? mudPointBalance
: null;
const visibleMudPointBalanceStatus = walletOwnerMatchesCurrentUser
? mudPointBalanceStatus
: 'idle';
const visibleMudPointBalanceError = walletOwnerMatchesCurrentUser
? mudPointBalanceError
: '';
useEffect(() => {
resetWalletBalance();
void onWalletBalanceMayHaveChanged();
setWalletOwner(currentUserId || null);
if (currentUserId) {
void onWalletBalanceMayHaveChanged();
}
const refreshWalletBalance = () => {
void onWalletBalanceMayHaveChanged();
if (currentUserId) {
void onWalletBalanceMayHaveChanged();
}
};
window.addEventListener('focus', refreshWalletBalance);
return () => {
window.removeEventListener('focus', refreshWalletBalance);
};
}, [currentUserId, onWalletBalanceMayHaveChanged, resetWalletBalance]);
}, [currentUserId, onWalletBalanceMayHaveChanged, setWalletOwner]);
useEffect(() => {
rechargeLifecycleRef.current += 1;
setWalletLedgerOpen(false);
setWalletLedger(null);
setWalletLedgerLoading(false);
setWalletLedgerError(null);
setRechargeOpen(false);
setRechargeContent(null);
setRechargeLoading(false);
setRechargeError(null);
setSubmittingRechargeProductId(null);
setNativeRechargePayment(null);
}, [currentUserId]);
async function loadWalletLedger() {
const rechargeLifecycle = rechargeLifecycleRef.current;
setWalletLedgerLoading(true);
setWalletLedgerError(null);
try {
setWalletLedger(await getClientProfileWalletLedger());
const ledger = await getClientProfileWalletLedger();
if (rechargeLifecycleRef.current === rechargeLifecycle) {
setWalletLedger(ledger);
}
} catch (error) {
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
return;
}
setWalletLedger(null);
setWalletLedgerError(
error instanceof Error ? error.message : '读取泥点账单失败',
);
} finally {
setWalletLedgerLoading(false);
if (rechargeLifecycleRef.current === rechargeLifecycle) {
setWalletLedgerLoading(false);
}
}
}
@@ -75,7 +115,10 @@ export function useAccountWallet(currentUserId: string) {
void loadWalletLedger();
}
function applyRechargeContent(center: ProfileRechargeCenterResponse) {
function applyRechargeContent(
snapshotOwnerUserId: string,
center: ProfileRechargeCenterResponse,
) {
const {
walletBalance,
mudPointBalance: nextMudPointBalance,
@@ -83,12 +126,15 @@ export function useAccountWallet(currentUserId: string) {
} = center;
void walletBalance;
if (nextMudPointBalance) {
applyWalletBalanceSnapshot(nextMudPointBalance);
applyWalletBalanceSnapshot(snapshotOwnerUserId, nextMudPointBalance);
} else {
void onWalletBalanceMayHaveChanged();
}
setRechargeContent(content);
}
async function loadRechargeCenter() {
const snapshotOwnerUserId = currentUserId;
const rechargeLifecycle = rechargeLifecycleRef.current;
setRechargeLoading(true);
setRechargeError(null);
@@ -97,7 +143,7 @@ export function useAccountWallet(currentUserId: string) {
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
return;
}
applyRechargeContent(center);
applyRechargeContent(snapshotOwnerUserId, center);
} catch (error) {
if (rechargeLifecycleRef.current === rechargeLifecycle) {
setRechargeError(
@@ -130,6 +176,7 @@ export function useAccountWallet(currentUserId: string) {
return;
}
const rechargeLifecycle = rechargeLifecycleRef.current;
const snapshotOwnerUserId = currentUserId;
setSubmittingRechargeProductId(product.productId);
setRechargeError(null);
try {
@@ -139,7 +186,7 @@ export function useAccountWallet(currentUserId: string) {
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
return;
}
applyRechargeContent(response.center);
applyRechargeContent(snapshotOwnerUserId, response.center);
const nativePayment = response.wechatNativePayment;
const codeUrl = nativePayment?.codeUrl?.trim();
const expiresAt = nativePayment?.expiresAt?.trim();
@@ -170,6 +217,7 @@ export function useAccountWallet(currentUserId: string) {
return;
}
const rechargeLifecycle = rechargeLifecycleRef.current;
const snapshotOwnerUserId = currentUserId;
const orderId = nativeRechargePayment.orderId;
setNativeRechargePayment((current) =>
current?.orderId === orderId
@@ -181,7 +229,7 @@ export function useAccountWallet(currentUserId: string) {
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
return;
}
applyRechargeContent(response.center);
applyRechargeContent(snapshotOwnerUserId, response.center);
if (response.order.status === 'paid') {
setNativeRechargePayment(null);
void onWalletBalanceMayHaveChanged();
@@ -214,9 +262,10 @@ export function useAccountWallet(currentUserId: string) {
}
return {
mudPointBalance,
mudPointBalanceStatus,
mudPointBalanceError,
ownerUserId,
mudPointBalance: visibleMudPointBalance,
mudPointBalanceStatus: visibleMudPointBalanceStatus,
mudPointBalanceError: visibleMudPointBalanceError,
onWalletBalanceMayHaveChanged,
resetWalletBalance,
walletLedgerOpen,
@@ -226,11 +275,11 @@ export function useAccountWallet(currentUserId: string) {
walletLedgerError,
rechargeOpen,
rechargeModalCenter:
rechargeContent && mudPointBalance
rechargeContent && visibleMudPointBalance
? {
...rechargeContent,
walletBalance: mudPointBalance.totalPoints,
mudPointBalance,
walletBalance: visibleMudPointBalance.totalPoints,
mudPointBalance: visibleMudPointBalance,
}
: null,
rechargeLoading,
@@ -137,10 +137,10 @@ export function getClientProfileDashboard() {
);
}
export function getClientProfileRechargeCenter() {
export function getClientProfileRechargeCenter(signal?: AbortSignal) {
return requestClientApi<ProfileRechargeCenterResponse>(
'/api/profile/recharge-center',
{ method: 'GET' },
{ method: 'GET', signal },
'读取泥点明细失败',
);
}
@@ -1,100 +1,6 @@
import { create } from 'zustand';
import type { ProfileMudPointBalance } from '../../../../packages/shared/src';
import { createProfileWalletStore } from '../../../../packages/shared/src';
import { getClientProfileRechargeCenter } from '../services/clientApi';
export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error';
type WalletStore = {
mudPointBalance: ProfileMudPointBalance | null;
mudPointBalanceStatus: MudPointBalanceStatus;
mudPointBalanceError: string;
applyWalletBalanceSnapshot: (balance: ProfileMudPointBalance) => void;
onWalletBalanceMayHaveChanged: () => Promise<void>;
resetWalletBalance: () => void;
};
const initialWalletState: Pick<
WalletStore,
'mudPointBalance' | 'mudPointBalanceStatus' | 'mudPointBalanceError'
> = {
mudPointBalance: null,
mudPointBalanceStatus: 'idle',
mudPointBalanceError: '',
};
let refreshVersion = 0;
let requestGeneration = 0;
let activeRefresh: Promise<void> | null = null;
export const useWalletStore = create<WalletStore>((set) => ({
...initialWalletState,
applyWalletBalanceSnapshot: (balance) => {
requestGeneration += 1;
refreshVersion = 0;
activeRefresh = null;
set({
mudPointBalance: balance,
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});
},
onWalletBalanceMayHaveChanged: () => {
refreshVersion += 1;
if (activeRefresh) {
return activeRefresh;
}
const refreshRequestGeneration = requestGeneration;
const refresh = (async () => {
while (refreshRequestGeneration === requestGeneration) {
const requestedVersion = refreshVersion;
set({ mudPointBalanceStatus: 'loading', mudPointBalanceError: '' });
try {
const center = await getClientProfileRechargeCenter();
if (!center.mudPointBalance) {
throw new Error('充值中心响应缺少泥点余额');
}
if (refreshRequestGeneration !== requestGeneration) {
return;
}
if (requestedVersion !== refreshVersion) {
continue;
}
set({
mudPointBalance: center.mudPointBalance,
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});
return;
} catch (error) {
if (refreshRequestGeneration !== requestGeneration) {
return;
}
if (requestedVersion !== refreshVersion) {
continue;
}
set({
mudPointBalanceStatus: 'error',
mudPointBalanceError:
error instanceof Error ? error.message : '泥点明细读取失败',
});
return;
}
}
})();
activeRefresh = refresh;
void refresh.finally(() => {
if (activeRefresh === refresh) {
activeRefresh = null;
}
});
return refresh;
},
resetWalletBalance: () => {
requestGeneration += 1;
refreshVersion = 0;
activeRefresh = null;
set(initialWalletState);
},
}));
export const useWalletStore = createProfileWalletStore({
getRechargeCenter: getClientProfileRechargeCenter,
});
@@ -1,15 +1,21 @@
/** @vitest-environment jsdom */
import { act, renderHook } from '@testing-library/react';
import { createElement } from 'react';
import { renderToString } from 'react-dom/server';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const clientApi = vi.hoisted(() => ({
confirmClientWechatProfileRechargeOrder: vi.fn(),
createClientProfileRechargeOrder: vi.fn(),
getClientProfileRechargeCenter: vi.fn(),
getClientProfileWalletLedger: vi.fn(),
}));
vi.mock('../src/services/clientApi', () => clientApi);
import { useWalletStore } from '../src/stores/useWalletStore';
import { useAccountWallet } from '../src/features/app-shell/useAccountWallet';
function detailedBalance(totalPoints: number) {
return {
@@ -26,6 +32,7 @@ function detailedBalance(totalPoints: number) {
describe('useWalletStore', () => {
beforeEach(() => {
useWalletStore.getState().resetWalletBalance();
useWalletStore.getState().setWalletOwner('user-a');
vi.clearAllMocks();
});
@@ -50,7 +57,9 @@ describe('useWalletStore', () => {
'mudPointBalanceError',
'mudPointBalanceStatus',
'onWalletBalanceMayHaveChanged',
'ownerUserId',
'resetWalletBalance',
'setWalletOwner',
]);
});
@@ -61,7 +70,9 @@ describe('useWalletStore', () => {
await useWalletStore.getState().onWalletBalanceMayHaveChanged();
const mudPointBalance = detailedBalance(120);
useWalletStore.getState().applyWalletBalanceSnapshot(mudPointBalance);
useWalletStore
.getState()
.applyWalletBalanceSnapshot('user-a', mudPointBalance);
expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance);
expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready');
@@ -79,7 +90,9 @@ describe('useWalletStore', () => {
const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged();
const mudPointBalance = detailedBalance(160);
useWalletStore.getState().applyWalletBalanceSnapshot(mudPointBalance);
useWalletStore
.getState()
.applyWalletBalanceSnapshot('user-a', mudPointBalance);
rejectRequest?.(new Error('过期请求失败'));
await refresh;
@@ -179,4 +192,57 @@ describe('useWalletStore', () => {
expect(useWalletStore.getState().mudPointBalance).toBeNull();
expect(useWalletStore.getState().mudPointBalanceStatus).toBe('idle');
});
test('clears immediately when the adapter switches wallet owners', () => {
useWalletStore
.getState()
.applyWalletBalanceSnapshot('user-a', detailedBalance(88));
useWalletStore.getState().setWalletOwner('user-b');
expect(useWalletStore.getState()).toMatchObject({
ownerUserId: 'user-b',
mudPointBalance: null,
mudPointBalanceStatus: 'idle',
});
});
test('masks another owner snapshot before the owner-binding effect runs', () => {
useWalletStore
.getState()
.applyWalletBalanceSnapshot('user-a', detailedBalance(88));
function WalletProbe() {
const wallet = useAccountWallet('user-b');
return createElement(
'span',
null,
wallet.mudPointBalance?.totalPoints ?? 'empty',
);
}
expect(renderToString(createElement(WalletProbe))).toContain('empty');
expect(useWalletStore.getState().ownerUserId).toBe('user-a');
});
test('binds the account owner and refreshes again when the window regains focus', async () => {
clientApi.getClientProfileRechargeCenter.mockResolvedValue({
walletBalance: 18,
mudPointBalance: detailedBalance(18),
});
renderHook(() => useAccountWallet('user-a'));
await act(async () => undefined);
expect(clientApi.getClientProfileRechargeCenter).toHaveBeenCalledTimes(1);
await act(async () => {
window.dispatchEvent(new Event('focus'));
});
expect(clientApi.getClientProfileRechargeCenter).toHaveBeenCalledTimes(2);
expect(useWalletStore.getState()).toMatchObject({
ownerUserId: 'user-a',
mudPointBalance: detailedBalance(18),
});
});
});
@@ -5931,3 +5931,12 @@
- Agent 发现:新增公开 `agent-integration.json``skill/SKILL.md``skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。
- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``.codex/skills/genarrative-external-editor-api/SKILL.md`
## 2026-08-03 主站与 AI Game Creator 复用单一泥点钱包 Store
- 背景:主站顶部优先读取 dashboard 总额,图片画板独立轮询 dashboard,充值 controller 和 AI Game Creator 又分别保存充值中心明细;不同请求返回时序不一致会让总额与分桶同时显示不同快照,快速生成或切换账号时旧响应还可能回滚余额。
- 决策:在 `packages/shared` 提供依赖注入式 `createProfileWalletStore`,统一保存 `ownerUserId`、完整 `ProfileMudPointBalance`、读取状态与错误,并在每个实例的独立闭包内完成请求合并、尾随补读、generation 失效和 owner 校验。主站注入 `getPlatformProfileRechargeCenter`AI Game Creator 注入 `getClientProfileRechargeCenter`;两端不共享 transport、认证或重试实现。主站顶部、图片画板顶部和“我的”统计只消费同一 Store 快照,总额固定取 `totalPoints`dashboard 的 `walletBalance` 只保留后端兼容,不再作为钱包 UI 数据源。
- 并发与账号边界:同一 owner generation 同时只执行一个余额读取;读取期间的新变化通知在当前请求结束后补读,直到覆盖最后一次通知。切换或退出账号立即清空快照、提升 generation、中止并脱离旧 generation 的 active 请求,新 owner 不得等待旧 transport 收束;不响应 abort 的旧 transport 可以在后台结束,但其结果必须忽略。消费端在 owner 绑定 effect 提交前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,不能让旧余额与新账号身份同屏。充值中心读取或 mutation 响应必须携带请求开始时捕获的 owner,owner 不符时忽略。刷新失败保留已有快照;响应缺少 `mudPointBalance` 时进入错误状态,不用总额反推分桶。
- UI 与 mutation:充值 controller 继续保存商品、订单和支付状态,但充值中心响应必须同步写入共享快照,余额 mutation 应在应用响应后再通知一次合并刷新。充值弹窗的余额和分桶由当前 Store 快照覆盖。生成完成、失败退款、兑换码和邀请奖励等事件只发送余额可能变化通知;账号变化同时清理旧充值中心、账单与支付临时状态。`limitedPoints` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。
- 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`
@@ -59,7 +59,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
## 账户与充值
1. 主站和图片画板统一使用公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及“每天重置为 20 泥点”,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。
1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。
2. 账户充值弹窗标题统一为“购买更多泥点”,当前版本只展示泥点商品,不展示会员页签、会员商品、购买会员或升级会员入口。底层会员数据与周期刷新能力继续保留用于存量兼容和结算,会员周期限时泥点不在当前版本前台展示。
3. 泥点默认商品固定为四档:`60 泥点 / ¥6``180 + 90 泥点 / ¥18``300 + 150 泥点 / ¥30``680 + 340 泥点 / ¥68``60` 档不加赠,后三档首次购买各加赠基础泥点的 `50%`;实际展示、下单校验和支付确认仍以后端返回的充值商品配置为准。
4. 首充加赠资格按泥点商品档位独立计算。用户买过 `points_180` 后,只影响 `points_180` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。
+1
View File
@@ -50,5 +50,6 @@ export type * from './contracts/visualNovel';
export * from './http';
export * from './llm/narrativeLanguage';
export * from './llm/parsers';
export * from './stores/createProfileWalletStore';
// should not export component, instead, they should be ref by relative path
@@ -0,0 +1,185 @@
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();
store.getState().applyWalletBalanceSnapshot('user-a', 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');
store.getState().applyWalletBalanceSnapshot('user-a', 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');
store.getState().applyWalletBalanceSnapshot('user-a', balance(55));
await store.getState().onWalletBalanceMayHaveChanged();
expect(store.getState()).toMatchObject({
mudPointBalance: balance(55),
mudPointBalanceStatus: 'error',
mudPointBalanceError: 'network down',
});
});
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('ignores a snapshot captured for a different owner', () => {
const store = createProfileWalletStore({
getRechargeCenter: vi.fn(),
});
store.getState().setWalletOwner('user-b');
store.getState().applyWalletBalanceSnapshot('user-a', balance(100));
expect(store.getState().mudPointBalance).toBeNull();
expect(store.getState().mudPointBalanceStatus).toBe('idle');
});
});
@@ -0,0 +1,152 @@
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 ProfileWalletStore = {
ownerUserId: string | null;
mudPointBalance: ProfileMudPointBalance | null;
mudPointBalanceStatus: MudPointBalanceStatus;
mudPointBalanceError: string;
setWalletOwner: (userId: string | null) => void;
applyWalletBalanceSnapshot: (
ownerUserId: string,
balance: ProfileMudPointBalance,
) => void;
onWalletBalanceMayHaveChanged: () => Promise<void>;
resetWalletBalance: () => void;
};
const EMPTY_WALLET_STATE = {
mudPointBalance: null,
mudPointBalanceStatus: 'idle',
mudPointBalanceError: '',
} as const;
export function createProfileWalletStore(
api: ProfileWalletApi,
): UseBoundStore<StoreApi<ProfileWalletStore>> {
let refreshVersion = 0;
let settledRefreshVersion = 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;
}
invalidateActiveRefresh();
settledRefreshVersion = refreshVersion;
set({
ownerUserId: normalizedUserId,
...EMPTY_WALLET_STATE,
});
},
applyWalletBalanceSnapshot: (ownerUserId, balance) => {
if (!ownerUserId || get().ownerUserId !== ownerUserId) {
return;
}
invalidateActiveRefresh();
settledRefreshVersion = refreshVersion;
set({
mudPointBalance: balance,
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});
},
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) {
throw new Error('充值中心响应缺少泥点余额');
}
settledRefreshVersion = requestedVersion;
set({
mudPointBalance: center.mudPointBalance,
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});
} catch (error) {
if (generation !== requestGeneration) {
return;
}
if (requestedVersion !== refreshVersion) {
settledRefreshVersion = requestedVersion;
continue;
}
settledRefreshVersion = requestedVersion;
set({
mudPointBalanceStatus: 'error',
mudPointBalanceError:
error instanceof Error ? error.message : '泥点明细读取失败',
});
}
}
})();
activeRefresh = refresh;
activeAbortController = abortController;
void refresh.finally(() => {
if (activeRefresh === refresh) {
activeRefresh = null;
}
if (activeAbortController === abortController) {
activeAbortController = null;
}
});
return refresh;
},
resetWalletBalance: () => {
invalidateActiveRefresh();
settledRefreshVersion = refreshVersion;
set({ ownerUserId: null, ...EMPTY_WALLET_STATE });
},
}));
}
@@ -13,6 +13,7 @@ import JSZip from 'jszip';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation';
import {
ApiClientError,
@@ -295,6 +296,7 @@ describe('ImageCanvasEditorView', () => {
});
beforeEach(() => {
usePlatformWalletStore.getState().resetWalletBalance();
loadFrontendRuntimeConfigMock.mockImplementation(() =>
immediateAsync({
imageEditorAgentSidebarEnabled: false,
@@ -594,6 +596,16 @@ 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',
});
render(
<AuthUiContext.Provider
value={createAuthValue({
@@ -615,12 +627,7 @@ describe('ImageCanvasEditorView', () => {
);
expect(await screen.findByLabelText('泥点 1,234')).toBeTruthy();
expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
});
expect(getPlatformProfileDashboardMock).not.toHaveBeenCalled();
});
it('opens the account modal from the canvas topbar avatar entry', async () => {
@@ -661,6 +668,16 @@ 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',
});
render(
<AuthUiContext.Provider
value={createAuthValue({
@@ -23,7 +23,7 @@ import {
loadEditorProject,
} from '../../services/image-editor/editorProjectClient';
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
@@ -300,8 +300,36 @@ export function ImageCanvasEditorView({
}: ImageCanvasEditorViewProps = {}) {
const authUi = useAuthUi();
const [, setGenerationPricingVersion] = useState(0);
const [walletBalance, setWalletBalance] = useState<number | null>(null);
const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false);
const walletOwnerUserId = usePlatformWalletStore(
(state) => state.ownerUserId,
);
const storedMudPointBalance = usePlatformWalletStore(
(state) => state.mudPointBalance,
);
const storedMudPointBalanceStatus = usePlatformWalletStore(
(state) => state.mudPointBalanceStatus,
);
const storedMudPointBalanceError = usePlatformWalletStore(
(state) => state.mudPointBalanceError,
);
const onWalletBalanceMayHaveChanged = usePlatformWalletStore(
(state) => state.onWalletBalanceMayHaveChanged,
);
const currentWalletOwnerUserId =
authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null;
const walletOwnerMatchesCurrentUser =
Boolean(currentWalletOwnerUserId) &&
walletOwnerUserId === currentWalletOwnerUserId;
const mudPointBalance = walletOwnerMatchesCurrentUser
? storedMudPointBalance
: null;
const mudPointBalanceStatus = walletOwnerMatchesCurrentUser
? storedMudPointBalanceStatus
: 'idle';
const mudPointBalanceError = walletOwnerMatchesCurrentUser
? storedMudPointBalanceError
: '';
const walletBalance = mudPointBalance?.totalPoints ?? null;
const editorRootRef = useRef<HTMLElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const assetListRef = useRef<HTMLDivElement | null>(null);
@@ -489,21 +517,6 @@ export function ImageCanvasEditorView({
},
[],
);
const refreshEditorWalletBalance = useCallback(() => {
if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) {
return;
}
void getPlatformProfileDashboard({
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
})
.then((dashboard) => {
setWalletBalance(dashboard.walletBalance);
})
.catch(() => undefined);
}, []);
const {
buyRechargeProduct,
closeNativeWechatPayment,
@@ -539,14 +552,12 @@ export function ImageCanvasEditorView({
activeTab: 'editor-canvas',
isAuthenticated: Boolean(authUi?.user),
showRechargeEntry,
onRechargeSuccess: refreshEditorWalletBalance,
requestLogin: () => authUiRef.current?.openLoginModal(),
currentUser: authUi?.user ?? null,
});
const refreshEditorWalletState = useCallback(() => {
refreshEditorWalletBalance();
loadRechargeCenter();
}, [loadRechargeCenter, refreshEditorWalletBalance]);
void onWalletBalanceMayHaveChanged();
}, [onWalletBalanceMayHaveChanged]);
const isAccountPaymentModalOpen =
isRewardCodeOpen ||
isRechargeOpen ||
@@ -568,66 +579,6 @@ export function ImageCanvasEditorView({
window.location.reload();
});
}, [authUi]);
useEffect(() => {
if (!authUi?.canAccessProtectedData || !authUi.user?.id) {
setWalletBalance(null);
setIsWalletBalanceLoading(false);
return;
}
let isMounted = true;
let requestId = 0;
const refreshWalletBalance = () => {
const currentRequestId = requestId + 1;
requestId = currentRequestId;
setIsWalletBalanceLoading(true);
void getPlatformProfileDashboard({
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
})
.then((dashboard) => {
if (!isMounted || currentRequestId !== requestId) {
return;
}
setWalletBalance(dashboard.walletBalance);
})
.catch(() => {
if (!isMounted || currentRequestId !== requestId) {
return;
}
setWalletBalance(null);
})
.finally(() => {
if (!isMounted || currentRequestId !== requestId) {
return;
}
setIsWalletBalanceLoading(false);
});
};
refreshWalletBalance();
const handleWindowFocus = () => {
refreshWalletBalance();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshWalletBalance();
}
};
window.addEventListener('focus', handleWindowFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
isMounted = false;
window.removeEventListener('focus', handleWindowFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [authUi?.canAccessProtectedData, authUi?.user?.id]);
const {
projectTitle,
setProjectTitle,
@@ -2317,10 +2268,10 @@ export function ImageCanvasEditorView({
projectRenameError,
layers,
walletBalance,
walletBreakdown: rechargeCenter?.mudPointBalance ?? null,
isWalletBalanceLoading,
walletBreakdown: mudPointBalance,
isWalletBalanceLoading: mudPointBalanceStatus === 'loading',
isWalletDetailsLoading: isLoadingRechargeCenter,
walletDetailsError: rechargeError,
walletDetailsError: rechargeError || mudPointBalanceError || null,
currentUser: authUi?.user,
assetExportStatus,
isExportingAssets,
@@ -48,6 +48,8 @@ describe('PlatformActiveProfileView', () => {
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
isLoadingWalletBalance={false}
mudPointBalance={null}
user={null}
/>,
);
@@ -68,6 +70,16 @@ describe('PlatformActiveProfileView', () => {
updatedAt: '2026-07-18T00:00:00.000Z',
}}
isLoadingDashboard={false}
isLoadingWalletBalance={false}
mudPointBalance={{
totalPoints: 108,
permanentPoints: 88,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 20,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
}}
user={authenticatedUser}
/>,
);
@@ -95,6 +107,8 @@ describe('PlatformActiveProfileView', () => {
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
isLoadingWalletBalance={false}
mudPointBalance={null}
user={authenticatedUser}
/>,
);
@@ -127,6 +141,8 @@ describe('PlatformActiveProfileView', () => {
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
isLoadingWalletBalance={false}
mudPointBalance={null}
user={authenticatedUser}
/>,
);
@@ -189,6 +205,8 @@ describe('PlatformActiveProfileView', () => {
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
isLoadingWalletBalance={false}
mudPointBalance={null}
user={authenticatedUser}
/>,
);
@@ -11,6 +11,12 @@ import {
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import type { AuthUser } from '@/packages/shared/src';
import type {
ProfileDashboardSummary,
ProfileMudPointBalance,
} from '@/packages/shared/src';
import profileClockImage from '../../../media/profile/_Image (1).png';
import profileGamepadImage from '../../../media/profile/_Image (2).png';
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
@@ -20,8 +26,6 @@ import profileCommunityImage from '../../../media/profile/_Image (7).png';
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
import profileMascotImage from '../../../media/profile/_Image (9).png';
import profilePointImage from '../../../media/profile/_Image.png';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
import { updateAuthProfile } from '../../services/authService';
import {
canUseNativeHostCapability,
@@ -58,6 +62,8 @@ import {
type PlatformActiveProfileViewProps = {
dashboard: ProfileDashboardSummary | null;
isLoadingDashboard: boolean;
isLoadingWalletBalance: boolean;
mudPointBalance: ProfileMudPointBalance | null;
onLogin: () => void;
onOpenApiKeys: () => void;
onOpenCommunity: () => void;
@@ -247,6 +253,8 @@ function formatTotalPlayTime(value: number) {
export function PlatformActiveProfileView({
dashboard,
isLoadingDashboard,
isLoadingWalletBalance,
mudPointBalance,
onLogin,
onOpenApiKeys,
onOpenCommunity,
@@ -540,9 +548,11 @@ export function PlatformActiveProfileView({
cardKey="wallet"
label="泥点余额"
value={
dashboard
? formatDashboardCount(dashboard.walletBalance)
: '暂不可用'
mudPointBalance
? formatDashboardCount(mudPointBalance.totalPoints)
: isLoadingWalletBalance
? '读取中'
: '暂不可用'
}
icon={Coins}
imageSrc={profilePointImage}
@@ -1,15 +1,17 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import { PlatformEntryFlowShellImpl } from './PlatformEntryActiveFlowShell';
import type { SelectionStage } from './platformEntryActiveTypes';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
const authUiMock = vi.hoisted(() => ({
value: {
user: null,
user: null as AuthUser | null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
openAccountModal: vi.fn(),
@@ -21,6 +23,11 @@ const responsiveMock = vi.hoisted(() => ({
isDesktopLayout: true,
}));
const profileClientMock = vi.hoisted(() => ({
getPlatformProfileDashboard: vi.fn(),
getPlatformProfileRechargeCenter: vi.fn(),
}));
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => authUiMock.value,
}));
@@ -83,9 +90,9 @@ vi.mock('../image-editor/ImageCanvasEditorView', () => ({
ImageCanvasEditorView: () => <main aria-label="图片画布编辑器" />,
}));
vi.mock('../../services/platform-entry/platformProfileClient', () => ({
getPlatformProfileDashboard: vi.fn(),
}));
vi.mock('../../services/platform-entry/platformProfileClient', () =>
profileClientMock,
);
vi.mock('./usePlatformProfileCenterController', () => ({
usePlatformProfileCenterController: () => ({
@@ -130,10 +137,179 @@ function StatefulPlatformEntryFlowShell({
describe('PlatformEntryActiveFlowShell', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/creation');
usePlatformWalletStore.getState().resetWalletBalance();
profileClientMock.getPlatformProfileDashboard.mockReset();
profileClientMock.getPlatformProfileRechargeCenter.mockReset();
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
authUiMock.value.openLoginModal.mockReset();
responsiveMock.isDesktopLayout = true;
});
it('uses one recharge-center snapshot for the topbar and profile wallet when dashboard differs', async () => {
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
authUiMock.value.canAccessProtectedData = true;
profileClientMock.getPlatformProfileDashboard.mockResolvedValue({
walletBalance: 999,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: null,
});
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
walletBalance: 888,
mudPointBalance: {
totalPoints: 207,
permanentPoints: 180,
limitedPoints: 7,
limitedExpiresAt: '2026-08-31T16:00:00Z',
dailyFreePoints: 20,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
},
});
const { rerender } = render(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={vi.fn()}
/>,
);
expect(await screen.findByLabelText('泥点 207')).toBeTruthy();
rerender(
<PlatformEntryFlowShellImpl
selectionStage="profile"
setSelectionStage={vi.fn()}
/>,
);
expect(
await screen.findByRole('button', { name: '泥点余额 207' }),
).toBeTruthy();
expect(screen.queryByText('999')).toBeNull();
});
it('clears the previous wallet synchronously when the authenticated account changes', async () => {
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '用户一',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
authUiMock.value.canAccessProtectedData = true;
profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null);
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
mudPointBalance: {
totalPoints: 66,
permanentPoints: 66,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 0,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
},
});
const { rerender } = render(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={vi.fn()}
/>,
);
await screen.findByLabelText('泥点 66');
let resolveNextOwner!: (value: unknown) => void;
profileClientMock.getPlatformProfileRechargeCenter.mockImplementation(
() =>
new Promise((resolve) => {
resolveNextOwner = resolve;
}),
);
authUiMock.value.user = { ...authUiMock.value.user, id: 'user-2' };
rerender(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={vi.fn()}
/>,
);
expect(screen.queryByLabelText('泥点 66')).toBeNull();
await waitFor(() => {
expect(usePlatformWalletStore.getState()).toMatchObject({
ownerUserId: 'user-2',
mudPointBalance: null,
});
});
resolveNextOwner({
mudPointBalance: {
totalPoints: 67,
permanentPoints: 67,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 0,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
},
});
await screen.findByLabelText('泥点 67');
});
it('refreshes the shared wallet when the authenticated page regains focus', async () => {
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
authUiMock.value.canAccessProtectedData = true;
profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null);
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
mudPointBalance: {
totalPoints: 10,
permanentPoints: 10,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 0,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
},
});
render(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={vi.fn()}
/>,
);
await screen.findByLabelText('泥点 10');
await act(async () => {
window.dispatchEvent(new Event('focus'));
});
await waitFor(() => {
expect(
profileClientMock.getPlatformProfileRechargeCenter,
).toHaveBeenCalledTimes(2);
});
});
it('keeps the active desktop rail and the shared account capsule', async () => {
const setSelectionStage = vi.fn();
const { container, rerender } = render(
@@ -25,6 +25,10 @@ import {
replaceAppHistoryPath,
} from '../../routing/activeAppPageRoutes';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import {
usePlatformWalletLifecycle,
usePlatformWalletStore,
} from '../../stores/usePlatformWalletStore';
import { useAuthUi } from '../auth/AuthUiContext';
import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel';
import { PlatformActionButton } from '../common/PlatformActionButton';
@@ -224,6 +228,36 @@ export function PlatformEntryFlowShellImpl({
const [isMobileDesktopGuideOpen, setIsMobileDesktopGuideOpen] =
useState(false);
const isDesktopLayout = usePlatformDesktopLayout();
const currentWalletOwnerUserId =
authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null;
usePlatformWalletLifecycle(
currentWalletOwnerUserId,
Boolean(authUi?.canAccessProtectedData),
);
const walletOwnerUserId = usePlatformWalletStore(
(state) => state.ownerUserId,
);
const storedMudPointBalance = usePlatformWalletStore(
(state) => state.mudPointBalance,
);
const storedMudPointBalanceStatus = usePlatformWalletStore(
(state) => state.mudPointBalanceStatus,
);
const storedMudPointBalanceError = usePlatformWalletStore(
(state) => state.mudPointBalanceError,
);
const walletOwnerMatchesCurrentUser =
Boolean(currentWalletOwnerUserId) &&
walletOwnerUserId === currentWalletOwnerUserId;
const mudPointBalance = walletOwnerMatchesCurrentUser
? storedMudPointBalance
: null;
const mudPointBalanceStatus = walletOwnerMatchesCurrentUser
? storedMudPointBalanceStatus
: 'idle';
const mudPointBalanceError = walletOwnerMatchesCurrentUser
? storedMudPointBalanceError
: '';
const refreshDashboard = useCallback(async () => {
if (!authUi?.user || !authUi.canAccessProtectedData) {
@@ -250,7 +284,6 @@ export function PlatformEntryFlowShellImpl({
activeTab: isProfileStage ? 'profile' : 'project',
isAuthenticated: Boolean(authUi?.user),
showRechargeEntry: true,
onRechargeSuccess: refreshDashboard,
requestLogin: () => authUi?.openLoginModal(),
currentUser: authUi?.user,
});
@@ -337,11 +370,7 @@ export function PlatformEntryFlowShellImpl({
}
const isAuthenticated = Boolean(authUi?.user);
const balance =
dashboard?.walletBalance ??
profileCenter.rechargeCenter?.mudPointBalance?.totalPoints ??
profileCenter.rechargeCenter?.walletBalance ??
null;
const balance = mudPointBalance?.totalPoints ?? null;
const isCreationStage =
!isProfileStage &&
(selectionStage === 'platform' || selectionStage === 'creation-home');
@@ -455,14 +484,9 @@ export function PlatformEntryFlowShellImpl({
<PlatformMudPointWalletEntry
variant={isDesktopLayout ? 'desktop' : 'mobile'}
balance={balance}
breakdown={
profileCenter.rechargeCenter?.mudPointBalance ?? null
}
isLoading={
isLoadingDashboard ||
profileCenter.isLoadingRechargeCenter
}
error={profileCenter.rechargeError}
breakdown={mudPointBalance}
isLoading={mudPointBalanceStatus === 'loading'}
error={mudPointBalanceError || null}
className={
isDesktopLayout
? 'platform-desktop-create-wallet-chip'
@@ -515,6 +539,10 @@ export function PlatformEntryFlowShellImpl({
<PlatformActiveProfileView
dashboard={dashboard}
isLoadingDashboard={isLoadingDashboard}
isLoadingWalletBalance={
mudPointBalanceStatus === 'loading'
}
mudPointBalance={mudPointBalance}
user={authUi?.user}
onLogin={() => authUi?.openLoginModal()}
onOpenApiKeys={() => setIsApiKeysOpen(true)}
@@ -36,6 +36,7 @@ import {
redeemPlatformProfileRewardCode,
watchWechatPlatformProfileRechargeOrder,
} from '../../services/platform-entry/platformProfileClient';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import {
type CopyFeedbackState,
useCopyFeedback,
@@ -354,6 +355,45 @@ export function usePlatformProfileCenterController({
const pendingWechatRechargeOrderIdRef = useRef<string | null>(null);
const confirmingWechatRechargeOrderIdRef = useRef<string | null>(null);
const rechargeCenterReadRevisionRef = useRef(0);
const currentUserId = currentUser?.id ?? '';
const walletOwnerUserId = usePlatformWalletStore(
(state) => state.ownerUserId,
);
const storedMudPointBalance = usePlatformWalletStore(
(state) => state.mudPointBalance,
);
const applyWalletBalanceSnapshot = usePlatformWalletStore(
(state) => state.applyWalletBalanceSnapshot,
);
const onWalletBalanceMayHaveChanged = usePlatformWalletStore(
(state) => state.onWalletBalanceMayHaveChanged,
);
const walletOwnerMatchesCurrentUser =
Boolean(currentUserId) && walletOwnerUserId === currentUserId;
const mudPointBalance = walletOwnerMatchesCurrentUser
? storedMudPointBalance
: null;
useEffect(() => {
rechargeCenterReadRevisionRef.current += 1;
pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setRechargeCenter(null);
setIsLoadingRechargeCenter(false);
setRechargeError(null);
setRechargePaymentResult(null);
setWechatRechargeOrderConfirmationState(null);
setNativeWechatPayment(null);
setSubmittingRechargeProductId(null);
setIsWalletLedgerOpen(false);
setWalletLedger(null);
setWalletLedgerError(null);
setIsLoadingWalletLedger(false);
setIsRewardCodeOpen(false);
setRewardCodeInput('');
setRewardCodeError(null);
setRewardCodeSuccess(null);
}, [currentUserId]);
// 中文注释:支持带邀请码 query 的直达场景,登录成功后自动打开兑换面板并复用同一套输入状态。
useEffect(() => {
@@ -377,18 +417,36 @@ export function usePlatformProfileCenterController({
}, [currentUser, pendingProfileInviteCode, requestLogin]);
const loadWalletLedger = useCallback(() => {
const snapshotOwnerUserId = currentUserId;
setWalletLedgerError(null);
setIsLoadingWalletLedger(true);
void getPlatformProfileWalletLedger()
.then(setWalletLedger)
.then((ledger) => {
if (
usePlatformWalletStore.getState().ownerUserId === snapshotOwnerUserId
) {
setWalletLedger(ledger);
}
})
.catch((error: unknown) => {
if (
usePlatformWalletStore.getState().ownerUserId !== snapshotOwnerUserId
) {
return;
}
setWalletLedger(null);
setWalletLedgerError(
error instanceof Error ? error.message : '读取泥点账单失败',
);
})
.finally(() => setIsLoadingWalletLedger(false));
}, []);
.finally(() => {
if (
usePlatformWalletStore.getState().ownerUserId === snapshotOwnerUserId
) {
setIsLoadingWalletLedger(false);
}
});
}, [currentUserId]);
const openWalletLedgerPanel = useCallback(() => {
setIsWalletLedgerOpen(true);
@@ -396,13 +454,30 @@ export function usePlatformProfileCenterController({
}, [loadWalletLedger]);
const applyRechargeCenter = useCallback(
(center: ProfileRechargeCenterResponse) => {
(center: ProfileRechargeCenterResponse, refreshWallet = false) => {
if (
!currentUserId ||
usePlatformWalletStore.getState().ownerUserId !== currentUserId
) {
return false;
}
rechargeCenterReadRevisionRef.current += 1;
setIsLoadingRechargeCenter(false);
setRechargeError(null);
setRechargeCenter(center);
if (center.mudPointBalance) {
applyWalletBalanceSnapshot(currentUserId, center.mudPointBalance);
}
if (refreshWallet || !center.mudPointBalance) {
void onWalletBalanceMayHaveChanged();
}
return true;
},
[],
[
applyWalletBalanceSnapshot,
currentUserId,
onWalletBalanceMayHaveChanged,
],
);
const loadRechargeCenter = useCallback(() => {
@@ -412,7 +487,7 @@ export function usePlatformProfileCenterController({
void getPlatformProfileRechargeCenter()
.then((center) => {
if (revision === rechargeCenterReadRevisionRef.current) {
setRechargeCenter(center);
applyRechargeCenter(center);
}
})
.catch((error: unknown) => {
@@ -429,7 +504,7 @@ export function usePlatformProfileCenterController({
setIsLoadingRechargeCenter(false);
}
});
}, []);
}, [applyRechargeCenter]);
const refreshRechargeState = useCallback(() => {
loadRechargeCenter();
@@ -473,7 +548,9 @@ export function usePlatformProfileCenterController({
.then((response) => {
const result = buildRechargePaymentResultForOrder(response.order);
const isPaid = result.kind === 'success';
applyRechargeCenter(response.center);
if (!applyRechargeCenter(response.center, true)) {
return;
}
pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
@@ -540,7 +617,9 @@ export function usePlatformProfileCenterController({
.then((response) => {
const result = buildRechargePaymentResultForOrder(response.order);
const isPaid = result.kind === 'success';
applyRechargeCenter(response.center);
if (!applyRechargeCenter(response.center, true)) {
return;
}
pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
@@ -616,7 +695,9 @@ export function usePlatformProfileCenterController({
.then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
applyRechargeCenter(response.center);
if (!applyRechargeCenter(response.center, true)) {
return;
}
const paymentHandled = await requestHostPayment({
payload: response.wechatMiniProgramPayParams,
orderId: response.order.orderId,
@@ -628,7 +709,9 @@ export function usePlatformProfileCenterController({
}
if (paymentChannel === WECHAT_JSAPI_PAYMENT_CHANNEL) {
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
applyRechargeCenter(response.center);
if (!applyRechargeCenter(response.center, true)) {
return;
}
setRechargePaymentResult({
kind: 'pending',
title: '正在打开微信支付',
@@ -648,7 +731,9 @@ export function usePlatformProfileCenterController({
confirmResponse.order,
);
const isPaid = result.kind === 'success';
applyRechargeCenter(confirmResponse.center);
if (!applyRechargeCenter(confirmResponse.center, true)) {
return;
}
setRechargePaymentResult(result);
if (result.kind !== 'pending') {
pendingWechatRechargeOrderIdRef.current = null;
@@ -672,7 +757,9 @@ export function usePlatformProfileCenterController({
throw new Error('微信 H5 支付链接生成失败');
}
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
applyRechargeCenter(response.center);
if (!applyRechargeCenter(response.center, true)) {
return;
}
setRechargePaymentResult({
kind: 'pending',
title: '正在打开微信支付',
@@ -689,7 +776,9 @@ export function usePlatformProfileCenterController({
throw new Error('微信 Native 支付链接生成失败');
}
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
applyRechargeCenter(response.center);
if (!applyRechargeCenter(response.center, true)) {
return;
}
setNativeWechatPayment({
...wechatNativePayment,
codeUrl,
@@ -778,7 +867,9 @@ export function usePlatformProfileCenterController({
}
const result = buildRechargePaymentResultForOrder(response.order);
const isPaid = result.kind === 'success';
applyRechargeCenter(response.center);
if (!applyRechargeCenter(response.center, true)) {
return;
}
if (result.kind !== 'pending') {
setNativeWechatPayment(null);
pendingWechatRechargeOrderIdRef.current = null;
@@ -839,7 +930,9 @@ export function usePlatformProfileCenterController({
}
const result = buildRechargePaymentResultForOrder(response.order);
applyRechargeCenter(response.center);
if (!applyRechargeCenter(response.center, true)) {
return;
}
if (result.kind === 'pending') {
await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS);
continue;
@@ -1034,6 +1127,7 @@ export function usePlatformProfileCenterController({
setReferralCenter(response.center);
setReferralRedeemCode('');
setReferralSuccess('已填写');
void onWalletBalanceMayHaveChanged();
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
@@ -1042,7 +1136,12 @@ export function usePlatformProfileCenterController({
);
})
.finally(() => setIsSubmittingReferralRedeem(false));
}, [isSubmittingReferralRedeem, onRechargeSuccess, referralRedeemCode]);
}, [
isSubmittingReferralRedeem,
onRechargeSuccess,
onWalletBalanceMayHaveChanged,
referralRedeemCode,
]);
const submitRewardCode = useCallback(() => {
if (isSubmittingRewardCode || !rewardCodeInput.trim()) {
@@ -1056,13 +1155,30 @@ export function usePlatformProfileCenterController({
.then((response: RedeemProfileRewardCodeResponse) => {
setRewardCodeInput('');
setRewardCodeSuccess(`已到账 ${response.amountGranted} 泥点`);
void onWalletBalanceMayHaveChanged();
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setRewardCodeError(error instanceof Error ? error.message : '兑换失败');
})
.finally(() => setIsSubmittingRewardCode(false));
}, [isSubmittingRewardCode, onRechargeSuccess, rewardCodeInput]);
}, [
isSubmittingRewardCode,
onRechargeSuccess,
onWalletBalanceMayHaveChanged,
rewardCodeInput,
]);
const rechargeModalCenter = useMemo(() => {
if (!walletOwnerMatchesCurrentUser || !rechargeCenter) {
return null;
}
return {
...rechargeCenter,
walletBalance: mudPointBalance?.totalPoints ?? 0,
mudPointBalance: mudPointBalance ?? undefined,
};
}, [mudPointBalance, rechargeCenter, walletOwnerMatchesCurrentUser]);
return {
closeNativeWechatPayment,
@@ -1086,7 +1202,7 @@ export function usePlatformProfileCenterController({
openRewardCodeModal,
openWalletLedgerPanel,
profilePopupPanel,
rechargeCenter,
rechargeCenter: rechargeModalCenter,
rechargeError,
rechargePaymentResult,
referralCenter,
@@ -142,10 +142,11 @@ export function revokePlatformProfileExternalApiKey(
export function getPlatformProfileRechargeCenter(
options: PlatformProfileRequestOptions = {},
signal?: AbortSignal,
) {
return requestPlatformProfileJson<ProfileRechargeCenterResponse>(
'/recharge-center',
{ method: 'GET' },
{ method: 'GET', signal },
'读取泥点购买信息失败',
options,
);
+52
View File
@@ -0,0 +1,52 @@
import { useEffect } from 'react';
import { createProfileWalletStore } from '@/packages/shared/src';
import { getPlatformProfileRechargeCenter } from '@/src/services/platform-entry/platformProfileClient.ts';
export const usePlatformWalletStore = createProfileWalletStore({
getRechargeCenter: (signal) =>
getPlatformProfileRechargeCenter({}, signal),
});
export function usePlatformWalletLifecycle(
currentUserId: string | null,
canAccessProtectedData: boolean,
) {
const setWalletOwner = usePlatformWalletStore(
(state) => state.setWalletOwner,
);
const onWalletBalanceMayHaveChanged = usePlatformWalletStore(
(state) => state.onWalletBalanceMayHaveChanged,
);
useEffect(() => {
const ownerUserId =
canAccessProtectedData && currentUserId ? currentUserId : null;
setWalletOwner(ownerUserId);
if (!ownerUserId) {
return undefined;
}
void onWalletBalanceMayHaveChanged();
const refreshWallet = () => {
void onWalletBalanceMayHaveChanged();
};
const refreshVisibleWallet = () => {
if (document.visibilityState === 'visible') {
refreshWallet();
}
};
window.addEventListener('focus', refreshWallet);
document.addEventListener('visibilitychange', refreshVisibleWallet);
return () => {
window.removeEventListener('focus', refreshWallet);
document.removeEventListener('visibilitychange', refreshVisibleWallet);
};
}, [
canAccessProtectedData,
currentUserId,
onWalletBalanceMayHaveChanged,
setWalletOwner,
]);
}
+1
View File
@@ -74,6 +74,7 @@ export default defineConfig({
'packages/shared/src/contracts/hostBridge.test.ts',
'packages/shared/src/components/**/*.test.ts',
'packages/shared/src/components/**/*.test.tsx',
'packages/shared/src/stores/**/*.test.ts',
'packages/shared/src/utils/**/*.test.ts',
],
exclude: [