From 3a4a8e20297fac25d32e39b20d16891baa5ba2a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 3 Aug 2026 19:56:38 +0800 Subject: [PATCH 01/36] =?UTF-8?q?=20=20=E7=BB=9F=E4=B8=80=E4=B8=BB?= =?UTF-8?q?=E7=AB=99=E4=B8=8EAI=E6=B8=B8=E6=88=8F=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E9=92=B1=E5=8C=85=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增共享泥点钱包 Store 并合并并发刷新 按账号 owner 隔离快照并屏蔽切换期间的旧余额 中止并脱离旧账号请求以保障新账号立即刷新 统一主站、图片编辑器和 AI Game Creator 钱包数据源 补充账号切换、悬挂请求和焦点刷新回归测试 同步更新项目基线与共享决策记录 --- .../features/app-shell/useAccountWallet.ts | 83 ++++++-- .../src/services/clientApi.ts | 4 +- .../src/stores/useWalletStore.ts | 102 +--------- .../tests/walletStore.test.ts | 70 ++++++- .../shared-memory/decision-log.md | 9 + ...项目基线】当前产品与工程约束-2026-05-15.md | 2 +- packages/shared/src/index.ts | 1 + .../stores/createProfileWalletStore.test.ts | 185 +++++++++++++++++ .../src/stores/createProfileWalletStore.ts | 152 ++++++++++++++ .../ImageCanvasEditorView.test.tsx | 29 ++- .../image-editor/ImageCanvasEditorView.tsx | 121 ++++-------- .../PlatformActiveProfileView.test.tsx | 18 ++ .../PlatformActiveProfileView.tsx | 20 +- .../PlatformEntryActiveFlowShell.test.tsx | 186 +++++++++++++++++- .../PlatformEntryActiveFlowShell.tsx | 56 ++++-- .../usePlatformProfileCenterController.ts | 154 +++++++++++++-- .../platform-entry/platformProfileClient.ts | 3 +- src/stores/usePlatformWalletStore.ts | 52 +++++ vitest.config.ts | 1 + 19 files changed, 993 insertions(+), 255 deletions(-) create mode 100644 packages/shared/src/stores/createProfileWalletStore.test.ts create mode 100644 packages/shared/src/stores/createProfileWalletStore.ts create mode 100644 src/stores/usePlatformWalletStore.ts diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 1c38a4c36..0cbe9fd66 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -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(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, diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index c16b24437..abfabb420 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -137,10 +137,10 @@ export function getClientProfileDashboard() { ); } -export function getClientProfileRechargeCenter() { +export function getClientProfileRechargeCenter(signal?: AbortSignal) { return requestClientApi( '/api/profile/recharge-center', - { method: 'GET' }, + { method: 'GET', signal }, '读取泥点明细失败', ); } diff --git a/apps/ai-game-creator-shell/src/stores/useWalletStore.ts b/apps/ai-game-creator-shell/src/stores/useWalletStore.ts index eba4d1b48..9f31d6e04 100644 --- a/apps/ai-game-creator-shell/src/stores/useWalletStore.ts +++ b/apps/ai-game-creator-shell/src/stores/useWalletStore.ts @@ -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; - resetWalletBalance: () => void; -}; - -const initialWalletState: Pick< - WalletStore, - 'mudPointBalance' | 'mudPointBalanceStatus' | 'mudPointBalanceError' -> = { - mudPointBalance: null, - mudPointBalanceStatus: 'idle', - mudPointBalanceError: '', -}; - -let refreshVersion = 0; -let requestGeneration = 0; -let activeRefresh: Promise | null = null; - -export const useWalletStore = create((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, +}); diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index 7a573f945..8d5c5b788 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -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), + }); + }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b5962f16f..7edfb6a44 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 082347839..75be4392b 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/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` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。 diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a6f0f50f8..da4bf484a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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 diff --git a/packages/shared/src/stores/createProfileWalletStore.test.ts b/packages/shared/src/stores/createProfileWalletStore.test.ts new file mode 100644 index 000000000..92399e2cf --- /dev/null +++ b/packages/shared/src/stores/createProfileWalletStore.test.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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(); + 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(); + 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(); + 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(); + const newOwner = deferred(); + const requestSignals: Array = []; + 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'); + }); +}); diff --git a/packages/shared/src/stores/createProfileWalletStore.ts b/packages/shared/src/stores/createProfileWalletStore.ts new file mode 100644 index 000000000..db1c703b9 --- /dev/null +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -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; +}; + +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; + resetWalletBalance: () => void; +}; + +const EMPTY_WALLET_STATE = { + mudPointBalance: null, + mudPointBalanceStatus: 'idle', + mudPointBalanceError: '', +} as const; + +export function createProfileWalletStore( + api: ProfileWalletApi, +): UseBoundStore> { + let refreshVersion = 0; + let settledRefreshVersion = 0; + let requestGeneration = 0; + let activeRefresh: Promise | null = null; + let activeAbortController: AbortController | null = null; + + const invalidateActiveRefresh = () => { + requestGeneration += 1; + activeAbortController?.abort(); + activeAbortController = null; + activeRefresh = null; + }; + + return create((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 }); + }, + })); +} diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 0182d2329..1db8e2570 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -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( { ); 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( (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(null); const canvasViewportRef = useRef(null); const assetListRef = useRef(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, diff --git a/src/components/platform-entry/PlatformActiveProfileView.test.tsx b/src/components/platform-entry/PlatformActiveProfileView.test.tsx index 8b3e46468..bbcb0b93e 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.test.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.test.tsx @@ -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} />, ); diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx index 1c99cada2..034ce759d 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.tsx @@ -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} diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index bf527b766..399141c47 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -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: () =>
, })); -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( + , + ); + + expect(await screen.findByLabelText('泥点 207')).toBeTruthy(); + + rerender( + , + ); + + 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( + , + ); + 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( + , + ); + + 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( + , + ); + 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( diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 8cd069729..0e0c4b236 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -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({ authUi?.openLoginModal()} onOpenApiKeys={() => setIsApiKeysOpen(true)} diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 94c8d2f71..605e6c30b 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -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(null); const confirmingWechatRechargeOrderIdRef = useRef(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, diff --git a/src/services/platform-entry/platformProfileClient.ts b/src/services/platform-entry/platformProfileClient.ts index 7164a8118..98a7e1ea7 100644 --- a/src/services/platform-entry/platformProfileClient.ts +++ b/src/services/platform-entry/platformProfileClient.ts @@ -142,10 +142,11 @@ export function revokePlatformProfileExternalApiKey( export function getPlatformProfileRechargeCenter( options: PlatformProfileRequestOptions = {}, + signal?: AbortSignal, ) { return requestPlatformProfileJson( '/recharge-center', - { method: 'GET' }, + { method: 'GET', signal }, '读取泥点购买信息失败', options, ); diff --git a/src/stores/usePlatformWalletStore.ts b/src/stores/usePlatformWalletStore.ts new file mode 100644 index 000000000..fbea92a11 --- /dev/null +++ b/src/stores/usePlatformWalletStore.ts @@ -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, + ]); +} diff --git a/vitest.config.ts b/vitest.config.ts index 844370c57..ca9e179c6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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: [ -- 2.52.0 From eaaa5f250be283634513c867f7d8d622d8a15a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 17:38:47 +0800 Subject: [PATCH 02/36] =?UTF-8?q?=E7=AE=80=E5=8C=96=E6=B3=A5=E7=82=B9?= =?UTF-8?q?=E4=BD=99=E9=A2=9D=E5=B1=95=E7=A4=BA=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提取泥点余额格式化函数,移除统计卡中的嵌套三元表达式。 --- .../PlatformActiveProfileView.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx index 034ce759d..27468698e 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.tsx @@ -243,6 +243,19 @@ function formatDashboardCount(value: number) { return Math.max(0, Math.round(value)).toLocaleString('zh-CN'); } +function formatWalletBalance( + balance: ProfileMudPointBalance | null, + isLoading: boolean, +) { + if (balance) { + return formatDashboardCount(balance.totalPoints); + } + if (isLoading) { + return '读取中'; + } + return '暂不可用'; +} + function formatTotalPlayTime(value: number) { const hours = Math.max(0, Math.round(value / 360000) / 10); return `${hours.toLocaleString('zh-CN', { @@ -547,13 +560,10 @@ export function PlatformActiveProfileView({ Date: Wed, 5 Aug 2026 17:40:08 +0800 Subject: [PATCH 03/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=85=E5=80=BC?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E8=AF=B7=E6=B1=82=E5=8F=96=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过现有请求选项透传 AbortSignal,确保共享钱包切换账号时可以中止旧请求。 新增充值中心信号透传回归测试并纳入 Vitest。 --- .../platformProfileClient.test.ts | 37 +++++++++++++++++++ .../platform-entry/platformProfileClient.ts | 3 +- src/stores/usePlatformWalletStore.ts | 2 +- vitest.config.ts | 1 + 4 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 src/services/platform-entry/platformProfileClient.test.ts diff --git a/src/services/platform-entry/platformProfileClient.test.ts b/src/services/platform-entry/platformProfileClient.test.ts new file mode 100644 index 000000000..a4f2e24e7 --- /dev/null +++ b/src/services/platform-entry/platformProfileClient.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const apiClientMocks = vi.hoisted(() => ({ + fetchWithApiAuth: vi.fn(), + requestJson: vi.fn(), +})); + +vi.mock('../apiClient', () => apiClientMocks); + +import { getPlatformProfileRechargeCenter } from './platformProfileClient'; + +describe('platformProfileClient', () => { + beforeEach(() => { + apiClientMocks.requestJson.mockReset(); + apiClientMocks.requestJson.mockResolvedValue({}); + }); + + test('forwards the recharge-center abort signal through request options', async () => { + const abortController = new AbortController(); + + await getPlatformProfileRechargeCenter({ + signal: abortController.signal, + }); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/profile/recharge-center', + { + method: 'GET', + signal: abortController.signal, + }, + '读取泥点购买信息失败', + expect.objectContaining({ + retry: expect.any(Object), + }), + ); + }); +}); diff --git a/src/services/platform-entry/platformProfileClient.ts b/src/services/platform-entry/platformProfileClient.ts index 98a7e1ea7..7164a8118 100644 --- a/src/services/platform-entry/platformProfileClient.ts +++ b/src/services/platform-entry/platformProfileClient.ts @@ -142,11 +142,10 @@ export function revokePlatformProfileExternalApiKey( export function getPlatformProfileRechargeCenter( options: PlatformProfileRequestOptions = {}, - signal?: AbortSignal, ) { return requestPlatformProfileJson( '/recharge-center', - { method: 'GET', signal }, + { method: 'GET' }, '读取泥点购买信息失败', options, ); diff --git a/src/stores/usePlatformWalletStore.ts b/src/stores/usePlatformWalletStore.ts index fbea92a11..89cb7017e 100644 --- a/src/stores/usePlatformWalletStore.ts +++ b/src/stores/usePlatformWalletStore.ts @@ -5,7 +5,7 @@ import { getPlatformProfileRechargeCenter } from '@/src/services/platform-entry/ export const usePlatformWalletStore = createProfileWalletStore({ getRechargeCenter: (signal) => - getPlatformProfileRechargeCenter({}, signal), + getPlatformProfileRechargeCenter({ signal }), }); export function usePlatformWalletLifecycle( diff --git a/vitest.config.ts b/vitest.config.ts index ca9e179c6..7672c4752 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -46,6 +46,7 @@ export default defineConfig({ 'src/services/image-editor/**/*.test.ts', 'src/services/external-generation/**/*.test.ts', 'src/services/payment/**/*.test.ts', + 'src/services/platform-entry/platformProfileClient.test.ts', 'src/components/auth/**/*.test.ts', 'src/components/auth/**/*.test.tsx', 'src/components/creation-home/**/*.test.ts', -- 2.52.0 From a6c5c901a137ab6b52e4b9eaee2ff112823f7939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 17:41:05 +0800 Subject: [PATCH 04/36] =?UTF-8?q?=E5=90=88=E5=B9=B6=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E9=92=B1=E5=8C=85=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将可见性与焦点事件合并到同一零延迟刷新任务,避免前台切换产生连续请求。 卸载时清理待执行定时器,并补充双事件回归测试。 --- .../PlatformEntryActiveFlowShell.test.tsx | 3 ++- src/stores/usePlatformWalletStore.ts | 20 ++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index 399141c47..cb836cc9d 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -267,7 +267,7 @@ describe('PlatformEntryActiveFlowShell', () => { await screen.findByLabelText('泥点 67'); }); - it('refreshes the shared wallet when the authenticated page regains focus', async () => { + it('coalesces visibility and focus refreshes when the page returns to the foreground', async () => { authUiMock.value.user = { id: 'user-1', publicUserCode: '100001', @@ -300,6 +300,7 @@ describe('PlatformEntryActiveFlowShell', () => { await screen.findByLabelText('泥点 10'); await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); window.dispatchEvent(new Event('focus')); }); diff --git a/src/stores/usePlatformWalletStore.ts b/src/stores/usePlatformWalletStore.ts index 89cb7017e..ff58a7b16 100644 --- a/src/stores/usePlatformWalletStore.ts +++ b/src/stores/usePlatformWalletStore.ts @@ -28,20 +28,30 @@ export function usePlatformWalletLifecycle( } void onWalletBalanceMayHaveChanged(); - const refreshWallet = () => { - void onWalletBalanceMayHaveChanged(); + let foregroundRefreshTimer: number | null = null; + const scheduleWalletRefresh = () => { + if (foregroundRefreshTimer !== null) { + return; + } + foregroundRefreshTimer = window.setTimeout(() => { + foregroundRefreshTimer = null; + void onWalletBalanceMayHaveChanged(); + }, 0); }; const refreshVisibleWallet = () => { if (document.visibilityState === 'visible') { - refreshWallet(); + scheduleWalletRefresh(); } }; - window.addEventListener('focus', refreshWallet); + window.addEventListener('focus', scheduleWalletRefresh); document.addEventListener('visibilitychange', refreshVisibleWallet); return () => { - window.removeEventListener('focus', refreshWallet); + window.removeEventListener('focus', scheduleWalletRefresh); document.removeEventListener('visibilitychange', refreshVisibleWallet); + if (foregroundRefreshTimer !== null) { + window.clearTimeout(foregroundRefreshTimer); + } }; }, [ canAccessProtectedData, -- 2.52.0 From 36da2732bf8831e528ef0dac01e03822b201e049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 17:46:06 +0800 Subject: [PATCH 05/36] =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=85=85=E5=80=BC?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E4=BD=99=E9=A2=9D=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 共享泥点明细暂不可用时继续使用充值中心响应中的兼容总额与明细。 补充充值弹窗余额兜底回归测试。 --- ...mProfileCenterController.recharge.test.tsx | 39 ++++++++++++++++ ...ormProfileCenterController.testSupport.tsx | 46 +++++++++++++++++++ .../usePlatformProfileCenterController.ts | 5 +- vitest.config.ts | 2 +- 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx create mode 100644 src/components/platform-entry/usePlatformProfileCenterController.testSupport.tsx diff --git a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx new file mode 100644 index 000000000..d333e061d --- /dev/null +++ b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx @@ -0,0 +1,39 @@ +/* @vitest-environment jsdom */ + +import { act, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; +import { + profileClientMocks, + renderController, + userA, +} from './usePlatformProfileCenterController.testSupport'; + +describe('usePlatformProfileCenterController recharge fallback', () => { + beforeEach(() => { + window.history.replaceState(null, '', '/profile'); + usePlatformWalletStore.getState().resetWalletBalance(); + vi.clearAllMocks(); + }); + + test('keeps the response legacy balance when no shared snapshot exists', async () => { + usePlatformWalletStore.getState().setWalletOwner('user-a'); + profileClientMocks.getPlatformProfileRechargeCenter.mockResolvedValue({ + walletBalance: 37, + membership: null, + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }); + const { result } = renderController(userA); + + act(() => result.current.loadRechargeCenter()); + + await waitFor(() => { + expect(result.current.rechargeCenter?.walletBalance).toBe(37); + }); + }); +}); diff --git a/src/components/platform-entry/usePlatformProfileCenterController.testSupport.tsx b/src/components/platform-entry/usePlatformProfileCenterController.testSupport.tsx new file mode 100644 index 000000000..77fc1b13c --- /dev/null +++ b/src/components/platform-entry/usePlatformProfileCenterController.testSupport.tsx @@ -0,0 +1,46 @@ +/* @vitest-environment jsdom */ + +import { renderHook } from '@testing-library/react'; +import { vi } from 'vitest'; + +import type { AuthUser } from '../../services/authService'; + +const profileClientMocks = vi.hoisted(() => ({ + confirmWechatPlatformProfileRechargeOrder: vi.fn(), + createPlatformProfileRechargeOrder: vi.fn(), + getPlatformProfileRechargeCenter: vi.fn(), + getPlatformProfileReferralInviteCenter: vi.fn(), + getPlatformProfileWalletLedger: vi.fn(), + redeemPlatformProfileReferralInviteCode: vi.fn(), + redeemPlatformProfileRewardCode: vi.fn(), + watchWechatPlatformProfileRechargeOrder: vi.fn(), +})); + +vi.mock('../../services/platform-entry/platformProfileClient', () => + profileClientMocks, +); + +export { profileClientMocks }; + +import { usePlatformProfileCenterController } from './usePlatformProfileCenterController'; + +export const userA = { id: 'user-a' } as AuthUser; +export const userB = { id: 'user-b' } as AuthUser; + +export function renderController( + currentUser: AuthUser, + onRechargeSuccess = vi.fn(), +) { + return renderHook( + ({ user }) => + usePlatformProfileCenterController({ + activeTab: 'settings', + isAuthenticated: true, + showRechargeEntry: true, + onRechargeSuccess, + requestLogin: vi.fn(), + currentUser: user, + }), + { initialProps: { user: currentUser } }, + ); +} diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 605e6c30b..4e15f6926 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -1175,8 +1175,9 @@ export function usePlatformProfileCenterController({ } return { ...rechargeCenter, - walletBalance: mudPointBalance?.totalPoints ?? 0, - mudPointBalance: mudPointBalance ?? undefined, + walletBalance: + mudPointBalance?.totalPoints ?? rechargeCenter.walletBalance, + mudPointBalance: mudPointBalance ?? rechargeCenter.mudPointBalance, }; }, [mudPointBalance, rechargeCenter, walletOwnerMatchesCurrentUser]); diff --git a/vitest.config.ts b/vitest.config.ts index 7672c4752..24b38f2d6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -65,7 +65,7 @@ export default defineConfig({ 'src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx', 'src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx', 'src/components/platform-entry/platformProfile*.test.ts', - 'src/components/platform-entry/usePlatformProfileCenterController.test.tsx', + 'src/components/platform-entry/usePlatformProfileCenterController*.test.tsx', 'src/hooks/useHostNavigationCanGoBack.test.tsx', 'apps/admin-web/src/**/*.test.ts', 'apps/admin-web/src/**/*.test.tsx', -- 2.52.0 From 314c861b0f73e483bef8418a734e8f553dadc334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 17:47:13 +0800 Subject: [PATCH 06/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E8=B4=A6=E5=8D=95=E8=AF=B7=E6=B1=82=E9=97=A8?= =?UTF-8?q?=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用控制器账号生命周期和账单请求 revision 校验响应,不再耦合钱包 Store owner 初始化。 确保当前账号的最新账单请求总能正确结束加载状态。 --- .../usePlatformProfileCenterController.ts | 20 +++++++++-- ...fileCenterController.walletLedger.test.tsx | 33 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 src/components/platform-entry/usePlatformProfileCenterController.walletLedger.test.tsx diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 4e15f6926..bd117ba93 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -355,7 +355,11 @@ export function usePlatformProfileCenterController({ const pendingWechatRechargeOrderIdRef = useRef(null); const confirmingWechatRechargeOrderIdRef = useRef(null); const rechargeCenterReadRevisionRef = useRef(0); + const accountLifecycleRevisionRef = useRef(0); + const walletLedgerReadRevisionRef = useRef(0); const currentUserId = currentUser?.id ?? ''; + const currentUserIdRef = useRef(currentUserId); + currentUserIdRef.current = currentUserId; const walletOwnerUserId = usePlatformWalletStore( (state) => state.ownerUserId, ); @@ -376,6 +380,8 @@ export function usePlatformProfileCenterController({ useEffect(() => { rechargeCenterReadRevisionRef.current += 1; + accountLifecycleRevisionRef.current += 1; + walletLedgerReadRevisionRef.current += 1; pendingWechatRechargeOrderIdRef.current = null; confirmingWechatRechargeOrderIdRef.current = null; setRechargeCenter(null); @@ -418,19 +424,25 @@ export function usePlatformProfileCenterController({ const loadWalletLedger = useCallback(() => { const snapshotOwnerUserId = currentUserId; + const accountRevision = accountLifecycleRevisionRef.current; + const revision = ++walletLedgerReadRevisionRef.current; setWalletLedgerError(null); setIsLoadingWalletLedger(true); void getPlatformProfileWalletLedger() .then((ledger) => { if ( - usePlatformWalletStore.getState().ownerUserId === snapshotOwnerUserId + revision === walletLedgerReadRevisionRef.current && + accountRevision === accountLifecycleRevisionRef.current && + currentUserIdRef.current === snapshotOwnerUserId ) { setWalletLedger(ledger); } }) .catch((error: unknown) => { if ( - usePlatformWalletStore.getState().ownerUserId !== snapshotOwnerUserId + revision !== walletLedgerReadRevisionRef.current || + accountRevision !== accountLifecycleRevisionRef.current || + currentUserIdRef.current !== snapshotOwnerUserId ) { return; } @@ -441,7 +453,9 @@ export function usePlatformProfileCenterController({ }) .finally(() => { if ( - usePlatformWalletStore.getState().ownerUserId === snapshotOwnerUserId + revision === walletLedgerReadRevisionRef.current && + accountRevision === accountLifecycleRevisionRef.current && + currentUserIdRef.current === snapshotOwnerUserId ) { setIsLoadingWalletLedger(false); } diff --git a/src/components/platform-entry/usePlatformProfileCenterController.walletLedger.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.walletLedger.test.tsx new file mode 100644 index 000000000..3efcfc2da --- /dev/null +++ b/src/components/platform-entry/usePlatformProfileCenterController.walletLedger.test.tsx @@ -0,0 +1,33 @@ +/* @vitest-environment jsdom */ + +import { act, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; +import { + profileClientMocks, + renderController, + userA, +} from './usePlatformProfileCenterController.testSupport'; + +describe('usePlatformProfileCenterController wallet ledger', () => { + beforeEach(() => { + window.history.replaceState(null, '', '/profile'); + usePlatformWalletStore.getState().resetWalletBalance(); + vi.clearAllMocks(); + }); + + test('settles a valid read without depending on wallet-store owner initialization', async () => { + profileClientMocks.getPlatformProfileWalletLedger.mockResolvedValue({ + entries: [], + }); + const { result } = renderController(userA); + + act(() => result.current.loadWalletLedger()); + + await waitFor(() => { + expect(result.current.walletLedger).toEqual({ entries: [] }); + expect(result.current.isLoadingWalletLedger).toBe(false); + }); + }); +}); -- 2.52.0 From 7e78e1bdaa5949fcdc67054745e2b5386b0e32a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 17:49:52 +0800 Subject: [PATCH 07/36] =?UTF-8?q?=E9=9A=94=E7=A6=BB=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=90=8E=E7=9A=84=E5=85=91=E6=8D=A2=E5=9B=9E?= =?UTF-8?q?=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 奖励码与邀请码兑换记录发起账号和账号生命周期 revision,忽略切换账号后的成功、失败与收尾回调。 账号切换时同步结束兑换提交态,并补充两类兑换回归测试。 --- ...rofileCenterController.redemption.test.tsx | 86 +++++++++++++++++++ .../usePlatformProfileCenterController.ts | 50 ++++++++++- 2 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx diff --git a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx new file mode 100644 index 000000000..0ac05b86f --- /dev/null +++ b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx @@ -0,0 +1,86 @@ +/* @vitest-environment jsdom */ + +import { act } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; +import { + profileClientMocks, + renderController, + userA, + userB, +} from './usePlatformProfileCenterController.testSupport'; + +describe('usePlatformProfileCenterController redemption lifecycle', () => { + beforeEach(() => { + window.history.replaceState(null, '', '/profile'); + usePlatformWalletStore.getState().resetWalletBalance(); + vi.clearAllMocks(); + }); + + test('ignores a reward-code response after the account changes', async () => { + let resolveReward!: (value: { + walletBalance: number; + amountGranted: number; + ledgerEntry: never; + }) => void; + profileClientMocks.redeemPlatformProfileRewardCode.mockImplementation( + () => + new Promise((resolve) => { + resolveReward = resolve; + }), + ); + const onRechargeSuccess = vi.fn(); + const { result, rerender } = renderController(userA, onRechargeSuccess); + act(() => result.current.setRewardCodeInput('reward-code')); + act(() => result.current.submitRewardCode()); + + rerender({ user: userB }); + await act(async () => { + resolveReward({ + walletBalance: 100, + amountGranted: 20, + ledgerEntry: undefined as never, + }); + }); + + expect(result.current.rewardCodeSuccess).toBeNull(); + expect(result.current.isSubmittingRewardCode).toBe(false); + expect(onRechargeSuccess).not.toHaveBeenCalled(); + }); + + test('ignores an invite-code response after the account changes', async () => { + let resolveReferral!: (value: { + center: never; + inviteeRewardGranted: boolean; + inviterRewardGranted: boolean; + inviteeBalanceAfter: number; + inviterBalanceAfter: number; + }) => void; + profileClientMocks.redeemPlatformProfileReferralInviteCode.mockImplementation( + () => + new Promise((resolve) => { + resolveReferral = resolve; + }), + ); + const onRechargeSuccess = vi.fn(); + const { result, rerender } = renderController(userA, onRechargeSuccess); + act(() => result.current.setReferralRedeemCode('invite-code')); + act(() => result.current.submitReferralRedeemCode()); + + rerender({ user: userB }); + await act(async () => { + resolveReferral({ + center: undefined as never, + inviteeRewardGranted: true, + inviterRewardGranted: false, + inviteeBalanceAfter: 100, + inviterBalanceAfter: 0, + }); + }); + + expect(result.current.referralSuccess).toBeNull(); + expect(result.current.isSubmittingReferralRedeem).toBe(false); + expect(onRechargeSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index bd117ba93..c4ef7cc31 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -397,8 +397,10 @@ export function usePlatformProfileCenterController({ setIsLoadingWalletLedger(false); setIsRewardCodeOpen(false); setRewardCodeInput(''); + setIsSubmittingRewardCode(false); setRewardCodeError(null); setRewardCodeSuccess(null); + setIsSubmittingReferralRedeem(false); }, [currentUserId]); // 中文注释:支持带邀请码 query 的直达场景,登录成功后自动打开兑换面板并复用同一套输入状态。 @@ -1136,8 +1138,16 @@ export function usePlatformProfileCenterController({ setIsSubmittingReferralRedeem(true); setReferralError(null); setReferralSuccess(null); + const snapshotOwnerUserId = currentUserId; + const accountRevision = accountLifecycleRevisionRef.current; void redeemPlatformProfileReferralInviteCode(inviteCode) .then((response) => { + if ( + accountRevision !== accountLifecycleRevisionRef.current || + currentUserIdRef.current !== snapshotOwnerUserId + ) { + return; + } setReferralCenter(response.center); setReferralRedeemCode(''); setReferralSuccess('已填写'); @@ -1145,12 +1155,26 @@ export function usePlatformProfileCenterController({ void onRechargeSuccess?.(); }) .catch((error: unknown) => { + if ( + accountRevision !== accountLifecycleRevisionRef.current || + currentUserIdRef.current !== snapshotOwnerUserId + ) { + return; + } setReferralError( error instanceof Error ? error.message : '填写邀请码失败', ); }) - .finally(() => setIsSubmittingReferralRedeem(false)); + .finally(() => { + if ( + accountRevision === accountLifecycleRevisionRef.current && + currentUserIdRef.current === snapshotOwnerUserId + ) { + setIsSubmittingReferralRedeem(false); + } + }); }, [ + currentUserId, isSubmittingReferralRedeem, onRechargeSuccess, onWalletBalanceMayHaveChanged, @@ -1165,18 +1189,40 @@ export function usePlatformProfileCenterController({ setIsSubmittingRewardCode(true); setRewardCodeError(null); setRewardCodeSuccess(null); + const snapshotOwnerUserId = currentUserId; + const accountRevision = accountLifecycleRevisionRef.current; void redeemPlatformProfileRewardCode(rewardCodeInput) .then((response: RedeemProfileRewardCodeResponse) => { + if ( + accountRevision !== accountLifecycleRevisionRef.current || + currentUserIdRef.current !== snapshotOwnerUserId + ) { + return; + } setRewardCodeInput(''); setRewardCodeSuccess(`已到账 ${response.amountGranted} 泥点`); void onWalletBalanceMayHaveChanged(); void onRechargeSuccess?.(); }) .catch((error: unknown) => { + if ( + accountRevision !== accountLifecycleRevisionRef.current || + currentUserIdRef.current !== snapshotOwnerUserId + ) { + return; + } setRewardCodeError(error instanceof Error ? error.message : '兑换失败'); }) - .finally(() => setIsSubmittingRewardCode(false)); + .finally(() => { + if ( + accountRevision === accountLifecycleRevisionRef.current && + currentUserIdRef.current === snapshotOwnerUserId + ) { + setIsSubmittingRewardCode(false); + } + }); }, [ + currentUserId, isSubmittingRewardCode, onRechargeSuccess, onWalletBalanceMayHaveChanged, -- 2.52.0 From f8e308be1598cd258305bbb1a727ef477f26006c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 17:51:03 +0800 Subject: [PATCH 08/36] =?UTF-8?q?=E5=B1=8F=E8=94=BD=E8=B7=A8=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E9=92=B1=E5=8C=85=E5=BC=B9=E7=AA=97=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 AI Game Creator 钱包本地弹窗状态绑定账号 owner,并与共享钱包 owner 一起同步控制可见性。 账号切换时立即隐藏旧账单、充值与支付数据,并补充回归测试。 --- .../features/app-shell/useAccountWallet.ts | 28 +++++++++++++------ .../tests/walletStore.test.ts | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 0cbe9fd66..7b57f9d96 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -43,8 +43,13 @@ export function useAccountWallet(currentUserId: string) { const [nativeRechargePayment, setNativeRechargePayment] = useState(null); const rechargeLifecycleRef = useRef(0); + const [walletUiOwnerUserId, setWalletUiOwnerUserId] = useState(currentUserId); const walletOwnerMatchesCurrentUser = Boolean(currentUserId) && ownerUserId === currentUserId; + const walletUiOwnerMatchesCurrentUser = + Boolean(currentUserId) && walletUiOwnerUserId === currentUserId; + const walletUiIsVisible = + walletOwnerMatchesCurrentUser && walletUiOwnerMatchesCurrentUser; const visibleMudPointBalance = walletOwnerMatchesCurrentUser ? mudPointBalance : null; @@ -74,6 +79,7 @@ export function useAccountWallet(currentUserId: string) { useEffect(() => { rechargeLifecycleRef.current += 1; + setWalletUiOwnerUserId(currentUserId); setWalletLedgerOpen(false); setWalletLedger(null); setWalletLedgerLoading(false); @@ -268,12 +274,12 @@ export function useAccountWallet(currentUserId: string) { mudPointBalanceError: visibleMudPointBalanceError, onWalletBalanceMayHaveChanged, resetWalletBalance, - walletLedgerOpen, + walletLedgerOpen: walletUiIsVisible && walletLedgerOpen, setWalletLedgerOpen, - walletLedger, - walletLedgerLoading, - walletLedgerError, - rechargeOpen, + walletLedger: walletUiIsVisible ? walletLedger : null, + walletLedgerLoading: walletUiIsVisible && walletLedgerLoading, + walletLedgerError: walletUiIsVisible ? walletLedgerError : null, + rechargeOpen: walletUiIsVisible && rechargeOpen, rechargeModalCenter: rechargeContent && visibleMudPointBalance ? { @@ -282,10 +288,14 @@ export function useAccountWallet(currentUserId: string) { mudPointBalance: visibleMudPointBalance, } : null, - rechargeLoading, - rechargeError, - submittingRechargeProductId, - nativeRechargePayment, + rechargeLoading: walletUiIsVisible && rechargeLoading, + rechargeError: walletUiIsVisible ? rechargeError : null, + submittingRechargeProductId: walletUiIsVisible + ? submittingRechargeProductId + : null, + nativeRechargePayment: walletUiIsVisible + ? nativeRechargePayment + : null, setNativeRechargePayment, loadWalletLedger, openWalletLedger, diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index 8d5c5b788..64b162f83 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -245,4 +245,32 @@ describe('useWalletStore', () => { mudPointBalance: detailedBalance(18), }); }); + + test('hides account-owned dialogs and data when the account changes', async () => { + clientApi.getClientProfileRechargeCenter.mockResolvedValue({ + walletBalance: 18, + mudPointBalance: detailedBalance(18), + }); + clientApi.getClientProfileWalletLedger.mockResolvedValue({ entries: [] }); + const { result, rerender } = renderHook( + ({ userId }) => useAccountWallet(userId), + { initialProps: { userId: 'user-a' } }, + ); + await act(async () => undefined); + await act(async () => { + result.current.openWalletLedger(); + result.current.openRecharge(); + }); + expect(result.current.walletLedgerOpen).toBe(true); + expect(result.current.rechargeOpen).toBe(true); + + await act(async () => { + rerender({ userId: 'user-b' }); + }); + + expect(result.current.walletLedgerOpen).toBe(false); + expect(result.current.walletLedger).toBeNull(); + expect(result.current.rechargeOpen).toBe(false); + expect(result.current.nativeRechargePayment).toBeNull(); + }); }); -- 2.52.0 From bf25b85c7ee1eef665350f094d3f42c0f8b7ab25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 17:52:05 +0800 Subject: [PATCH 09/36] =?UTF-8?q?=E8=A7=A3=E8=80=A6=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E4=B8=8E=E5=85=85=E5=80=BC=E8=AF=B7=E6=B1=82=E7=94=9F=E5=91=BD?= =?UTF-8?q?=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 AI Game Creator 账单读取使用独立 lifecycle token,并在账号切换时失效旧请求。 补充充值弹窗变化期间账单仍可正常收尾的回归测试。 --- .../features/app-shell/useAccountWallet.ts | 22 +++++++++++--- .../tests/walletStore.test.ts | 29 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 7b57f9d96..78b1c8dab 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -43,7 +43,10 @@ export function useAccountWallet(currentUserId: string) { const [nativeRechargePayment, setNativeRechargePayment] = useState(null); const rechargeLifecycleRef = useRef(0); + const walletLedgerLifecycleRef = useRef(0); const [walletUiOwnerUserId, setWalletUiOwnerUserId] = useState(currentUserId); + const currentUserIdRef = useRef(currentUserId); + currentUserIdRef.current = currentUserId; const walletOwnerMatchesCurrentUser = Boolean(currentUserId) && ownerUserId === currentUserId; const walletUiOwnerMatchesCurrentUser = @@ -79,6 +82,7 @@ export function useAccountWallet(currentUserId: string) { useEffect(() => { rechargeLifecycleRef.current += 1; + walletLedgerLifecycleRef.current += 1; setWalletUiOwnerUserId(currentUserId); setWalletLedgerOpen(false); setWalletLedger(null); @@ -93,16 +97,23 @@ export function useAccountWallet(currentUserId: string) { }, [currentUserId]); async function loadWalletLedger() { - const rechargeLifecycle = rechargeLifecycleRef.current; + const walletLedgerLifecycle = ++walletLedgerLifecycleRef.current; + const snapshotOwnerUserId = currentUserId; setWalletLedgerLoading(true); setWalletLedgerError(null); try { const ledger = await getClientProfileWalletLedger(); - if (rechargeLifecycleRef.current === rechargeLifecycle) { + if ( + walletLedgerLifecycleRef.current === walletLedgerLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { setWalletLedger(ledger); } } catch (error) { - if (rechargeLifecycleRef.current !== rechargeLifecycle) { + if ( + walletLedgerLifecycleRef.current !== walletLedgerLifecycle || + currentUserIdRef.current !== snapshotOwnerUserId + ) { return; } setWalletLedger(null); @@ -110,7 +121,10 @@ export function useAccountWallet(currentUserId: string) { error instanceof Error ? error.message : '读取泥点账单失败', ); } finally { - if (rechargeLifecycleRef.current === rechargeLifecycle) { + if ( + walletLedgerLifecycleRef.current === walletLedgerLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { setWalletLedgerLoading(false); } } diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index 64b162f83..2fb483e16 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -246,6 +246,35 @@ describe('useWalletStore', () => { }); }); + test('keeps ledger loading independent from the recharge lifecycle', async () => { + let resolveLedger!: (value: { entries: [] }) => void; + clientApi.getClientProfileRechargeCenter.mockResolvedValue({ + walletBalance: 18, + mudPointBalance: detailedBalance(18), + }); + clientApi.getClientProfileWalletLedger.mockImplementation( + () => + new Promise((resolve) => { + resolveLedger = resolve; + }), + ); + const { result } = renderHook(() => useAccountWallet('user-a')); + await act(async () => undefined); + + act(() => { + result.current.openWalletLedger(); + result.current.openRecharge(); + }); + expect(result.current.walletLedgerLoading).toBe(true); + + await act(async () => { + resolveLedger({ entries: [] }); + }); + + expect(result.current.walletLedger).toEqual({ entries: [] }); + expect(result.current.walletLedgerLoading).toBe(false); + }); + test('hides account-owned dialogs and data when the account changes', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValue({ walletBalance: 18, -- 2.52.0 From a7010587809a0c9dd7b65f5bc4755034a441bcfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 17:52:53 +0800 Subject: [PATCH 10/36] =?UTF-8?q?=E9=81=BF=E5=85=8D=E9=92=B1=E5=8C=85?= =?UTF-8?q?=E6=B8=85=E7=90=86=E4=BA=A7=E7=94=9F=E6=9C=AA=E5=A4=84=E7=90=86?= =?UTF-8?q?=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用同时处理成功与失败的 Promise 回调清理活动请求,避免 finally 派生拒绝无人观察。 --- packages/shared/src/stores/createProfileWalletStore.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/stores/createProfileWalletStore.ts b/packages/shared/src/stores/createProfileWalletStore.ts index db1c703b9..02497b042 100644 --- a/packages/shared/src/stores/createProfileWalletStore.ts +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -133,14 +133,15 @@ export function createProfileWalletStore( })(); activeRefresh = refresh; activeAbortController = abortController; - void refresh.finally(() => { + const clearActiveRefresh = () => { if (activeRefresh === refresh) { activeRefresh = null; } if (activeAbortController === abortController) { activeAbortController = null; } - }); + }; + void refresh.then(clearActiveRefresh, clearActiveRefresh); return refresh; }, resetWalletBalance: () => { -- 2.52.0 From 327cf3d2a8c941213ea4b6003685cbede770b7ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 17:53:18 +0800 Subject: [PATCH 11/36] =?UTF-8?q?=E5=BD=92=E4=B8=80=E5=8C=96=E9=92=B1?= =?UTF-8?q?=E5=8C=85=E5=BF=AB=E7=85=A7=E8=B4=A6=E5=8F=B7=E6=A0=87=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在应用外部钱包快照前统一 trim owner ID,保持与 owner 绑定入口相同的边界规则。 补充带前后空白的同账号快照回归测试。 --- .../src/stores/createProfileWalletStore.test.ts | 16 ++++++++++++++++ .../src/stores/createProfileWalletStore.ts | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/stores/createProfileWalletStore.test.ts b/packages/shared/src/stores/createProfileWalletStore.test.ts index 92399e2cf..39502b4a9 100644 --- a/packages/shared/src/stores/createProfileWalletStore.test.ts +++ b/packages/shared/src/stores/createProfileWalletStore.test.ts @@ -182,4 +182,20 @@ describe('createProfileWalletStore', () => { 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'); + + store + .getState() + .applyWalletBalanceSnapshot(' user-a ', balance(100)); + + expect(store.getState()).toMatchObject({ + mudPointBalance: balance(100), + mudPointBalanceStatus: 'ready', + }); + }); }); diff --git a/packages/shared/src/stores/createProfileWalletStore.ts b/packages/shared/src/stores/createProfileWalletStore.ts index 02497b042..527c5ff08 100644 --- a/packages/shared/src/stores/createProfileWalletStore.ts +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -64,7 +64,11 @@ export function createProfileWalletStore( }); }, applyWalletBalanceSnapshot: (ownerUserId, balance) => { - if (!ownerUserId || get().ownerUserId !== ownerUserId) { + const normalizedOwnerUserId = ownerUserId.trim(); + if ( + !normalizedOwnerUserId || + get().ownerUserId !== normalizedOwnerUserId + ) { return; } -- 2.52.0 From 02e4e18068035f875674fc50738b6863e9a6a404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 17:56:36 +0800 Subject: [PATCH 12/36] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=8D=95=E4=B8=80?= =?UTF-8?q?=E9=92=B1=E5=8C=85=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E7=BA=A6?= =?UTF-8?q?=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录请求取消、前台事件合并、账号异步隔离与兼容余额兜底规则。 明确 AI Game Creator 账单与充值使用独立 lifecycle。 --- docs/project-memory/shared-memory/decision-log.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7edfb6a44..a67fbc6b5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5938,5 +5938,7 @@ - 决策:在 `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` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。 +- 2026-08-05 审查补充:主站 transport adapter 必须通过既有请求 options 真实透传 `AbortSignal`;页面恢复时相邻的 `visibilitychange / focus` 合并为一次余额通知,并在卸载时清理待执行任务。Store 的 owner 输入统一在边界 trim,活动请求清理同时观察 Promise 成功与失败,不能用无人接收的 `finally` 派生 Promise。 +- 2026-08-05 账号隔离补充:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`。 - 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 -- 2.52.0 From 1f3d3f1aa15a37b40652ebe1a67dd0453e4f2dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 19:25:21 +0800 Subject: [PATCH 13/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=BB=E7=AB=99?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E7=94=9F=E6=88=90=E4=BB=BB=E5=8A=A1=E6=B3=A5?= =?UTF-8?q?=E7=82=B9=E5=88=B7=E6=96=B0=E6=97=B6=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用主站任务轮询推动共享钱包余额收敛 覆盖运行中扣费与失败退款后的余额刷新 支持其他项目任务驱动账号级钱包更新 补充主站外部生成任务回归测试 同步更新项目基线与共享决策记录 --- .../shared-memory/decision-log.md | 1 + ...项目基线】当前产品与工程约束-2026-05-15.md | 2 +- .../image-editor/ImageCanvasEditorView.tsx | 8 +- .../image-editor/ImageCanvasStageView.tsx | 5 +- .../ImageCanvasTaskSidebarView.test.tsx | 141 ++++++++++++++++++ .../ImageCanvasTaskSidebarView.tsx | 36 ++++- 6 files changed, 179 insertions(+), 14 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a67fbc6b5..8f6970d15 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5940,5 +5940,6 @@ - UI 与 mutation:充值 controller 继续保存商品、订单和支付状态,但充值中心响应必须同步写入共享快照,余额 mutation 应在应用响应后再通知一次合并刷新。充值弹窗的余额和分桶由当前 Store 快照覆盖。生成完成、失败退款、兑换码和邀请奖励等事件只发送余额可能变化通知;账号变化同时清理旧充值中心、账单与支付临时状态。`limitedPoints` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。 - 2026-08-05 审查补充:主站 transport adapter 必须通过既有请求 options 真实透传 `AbortSignal`;页面恢复时相邻的 `visibilitychange / focus` 合并为一次余额通知,并在卸载时清理待执行任务。Store 的 owner 输入统一在边界 trim,活动请求清理同时观察 Promise 成功与失败,不能用无人接收的 `finally` 派生 Promise。 - 2026-08-05 账号隔离补充:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`。 +- 2026-08-05 生成扣退费时序补充:external generation 入队时尚未扣费,worker 领取为 `running` 后的资产操作才预扣,业务失败则先退款再写任务失败态。主站钱包因此以账号下全局 active external task 为轮询生命周期,每轮成功状态读取都通知共享 Store 合并刷新,终态轮同时覆盖成功结算与失败退款;画布内容刷新仍只限当前项目的 `completed`,不因其它项目或 `failed` 刷新画布。 - 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 75be4392b..311de537d 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -59,7 +59,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 ## 账户与充值 -1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 +1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 2. 账户充值弹窗标题统一为“购买更多泥点”,当前版本只展示泥点商品,不展示会员页签、会员商品、购买会员或升级会员入口。底层会员数据与周期刷新能力继续保留用于存量兼容和结算,会员周期限时泥点不在当前版本前台展示。 3. 泥点默认商品固定为四档:`60 泥点 / ¥6`、`180 + 90 泥点 / ¥18`、`300 + 150 泥点 / ¥30`、`680 + 340 泥点 / ¥68`。`60` 档不加赠,后三档首次购买各加赠基础泥点的 `50%`;实际展示、下单校验和支付确认仍以后端返回的充值商品配置为准。 4. 首充加赠资格按泥点商品档位独立计算。用户买过 `points_180` 后,只影响 `points_180` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index faeabcd8f..5374624b2 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1463,13 +1463,8 @@ export function ImageCanvasEditorView({ if (warning) { showGenerationWarning(warning); } - refreshEditorWalletState(); }, - [ - projectId, - refreshEditorWalletState, - showGenerationWarning, - ], + [projectId, showGenerationWarning], ); const effectiveIsAgentConversationOpen = isAgentConversationEnabled && isAgentConversationOpen; @@ -2374,6 +2369,7 @@ export function ImageCanvasEditorView({ onActivateGenerationDialog: activateCanvasGenerationDialog, onFocusExternalTask: focusExternalGenerationTask, onExternalTasksCompleted: handleExternalGenerationTasksCompleted, + onExternalTaskWalletMayHaveChanged: refreshEditorWalletState, onEditorAgentConfirmSent: handleEditorAgentConfirmSent, onToggleTaskSidebar: toggleTaskSidebar, onToggleAgentConversation: toggleAgentConversation, diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 1a9f6ae1b..cbafaf6ae 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -134,6 +134,7 @@ export type ImageCanvasStageViewProps = { onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void; onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void; + onExternalTaskWalletMayHaveChanged?: () => void; onEditorAgentConfirmSent?: () => void; onToggleTaskSidebar: () => void; onToggleAgentConversation: () => void; @@ -270,6 +271,7 @@ export function ImageCanvasStageView({ onActivateGenerationDialog, onFocusExternalTask, onExternalTasksCompleted, + onExternalTaskWalletMayHaveChanged, onEditorAgentConfirmSent, onToggleTaskSidebar, onToggleAgentConversation, @@ -397,7 +399,7 @@ export function ImageCanvasStageView({ selectedToolbarStyle={selectedToolbarStyle} isSplittingIconSpritesheet={Boolean( selectedLayer && - splittingIconSpritesheetLayerIds.has(selectedLayer.id), + splittingIconSpritesheetLayerIds.has(selectedLayer.id), )} isPersistingAssetKind={Boolean( selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id), @@ -502,6 +504,7 @@ export function ImageCanvasStageView({ onToggleOpen={onToggleTaskSidebar} onFocusExternalTask={onFocusExternalTask} onExternalTasksCompleted={onExternalTasksCompleted} + onExternalTaskWalletMayHaveChanged={onExternalTaskWalletMayHaveChanged} /> {isAgentConversationEnabled ? ( diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index 7808a5f4b..028b77bf8 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -474,6 +474,147 @@ describe('ImageCanvasTaskSidebarView', () => { } }); + it('refreshes the wallet while an external task is running and after a failed refund settles', async () => { + vi.useFakeTimers(); + try { + const onExternalTasksCompleted = vi.fn(); + const onExternalTaskWalletMayHaveChanged = vi.fn(); + let activeRequestCount = 0; + const runningTask = createExternalTask({ + jobId: 'wallet-running-task', + requestLabel: '长耗时图片生成', + status: 'running', + priceMudPoints: 20, + }); + const failedTask = createExternalTask({ + ...runningTask, + status: 'failed', + progress: 0, + phaseDetail: '生成失败。', + error: '生成失败,泥点已退回。', + completedAt: new Date().toISOString(), + }); + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => { + if (options.statuses?.includes('running')) { + activeRequestCount += 1; + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: activeRequestCount === 1 ? 1 : 0, + unacknowledgedTerminalCount: activeRequestCount === 1 ? 0 : 1, + updatedAtMicros: activeRequestCount, + }, + tasks: activeRequestCount === 1 ? [runningTask] : [], + }); + } + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: activeRequestCount > 1 ? 1 : 0, + updatedAtMicros: activeRequestCount, + }, + tasks: activeRequestCount > 1 ? [failedTask] : [], + }); + }, + ); + + render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + await Promise.resolve(); + }); + + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2); + expect(onExternalTasksCompleted).not.toHaveBeenCalled(); + expect(refreshCanvasMock).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps wallet polling global when the active external task belongs to another project', async () => { + vi.useFakeTimers(); + try { + const onExternalTaskWalletMayHaveChanged = vi.fn(); + let activeRequestCount = 0; + const otherProjectTask = createExternalTask({ + jobId: 'other-project-wallet-task', + sourceEntityId: 'project-other', + status: 'running', + }); + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => { + if (options.statuses?.includes('running')) { + activeRequestCount += 1; + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: activeRequestCount === 1 ? 1 : 0, + unacknowledgedTerminalCount: 0, + updatedAtMicros: activeRequestCount, + }, + tasks: activeRequestCount === 1 ? [otherProjectTask] : [], + }); + } + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: 0, + updatedAtMicros: activeRequestCount, + }, + tasks: [], + }); + }, + ); + + render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.queryByText('发光猫咪主视觉')).toBeNull(); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + it('loads more completed tasks while scrolling and keeps the request capped', async () => { const completedTasks = Array.from({ length: 120 }, (_, index) => { const completedAt = new Date(Date.now() - index * 1000).toISOString(); diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx index d987ce431..07be2f2e9 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -49,6 +49,7 @@ type ImageCanvasTaskSidebarViewProps = { onToggleOpen: () => void; onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void; + onExternalTaskWalletMayHaveChanged?: () => void; }; function parseTaskTimeMs(value?: string | null) { @@ -280,6 +281,7 @@ export function ImageCanvasTaskSidebarView({ onToggleOpen, onFocusExternalTask, onExternalTasksCompleted, + onExternalTaskWalletMayHaveChanged, }: ImageCanvasTaskSidebarViewProps) { const { refreshCanvas } = useImageCanvasActions(); const projectId = useImageCanvasContextStore((state) => state.projectId); @@ -289,6 +291,8 @@ export function ImageCanvasTaskSidebarView({ const [externalTasks, setExternalTasks] = useState< ExternalGenerationTaskRecord[] >([]); + const [walletActiveExternalTaskIds, setWalletActiveExternalTaskIds] = + useState([]); const [completedListState, setCompletedListState] = useState(() => ({ limit: COMPLETED_TASK_LIST_LIMIT, projectId: normalizedProjectId, @@ -372,12 +376,21 @@ export function ImageCanvasTaskSidebarView({ if (disposed) { return; } - const visibleActiveTasks = filterVisibleExternalTasks( - activeResponse.tasks, - ); + const activeTasks = activeResponse.tasks.filter(isActiveExternalTask); + const visibleActiveTasks = filterVisibleExternalTasks(activeTasks); const visibleCompletedTasks = filterVisibleExternalTasks( completedResponse.tasks, ); + setWalletActiveExternalTaskIds(activeTasks.map((task) => task.jobId)); + if ( + activeTasks.length > 0 || + completedResponse.tasks.some( + (task) => + isTerminalExternalTask(task) && !task.notificationAcknowledgedAt, + ) + ) { + onExternalTaskWalletMayHaveChanged?.(); + } notifyCompletedExternalTasks(visibleCompletedTasks, { unacknowledgedOnly: true, }); @@ -401,6 +414,7 @@ export function ImageCanvasTaskSidebarView({ completedListLimit, filterVisibleExternalTasks, notifyCompletedExternalTasks, + onExternalTaskWalletMayHaveChanged, refreshKey, ]); @@ -412,13 +426,14 @@ export function ImageCanvasTaskSidebarView({ [externalTasks], ); const activeExternalTaskKey = activeExternalTaskIds.join('|'); + const walletActiveExternalTaskKey = walletActiveExternalTaskIds.join('|'); useEffect(() => { activeExternalTaskIdsRef.current = new Set(activeExternalTaskIds); }, [activeExternalTaskKey, activeExternalTaskIds]); useEffect(() => { - if (!activeExternalTaskIds.length) { + if (!walletActiveExternalTaskKey) { return undefined; } let disposed = false; @@ -448,6 +463,15 @@ export function ImageCanvasTaskSidebarView({ filterVisibleExternalTasks(activeResponse.tasks), filterVisibleExternalTasks(completedResponse.tasks), ); + setWalletActiveExternalTaskIds( + activeResponse.tasks + .filter(isActiveExternalTask) + .map((task) => task.jobId), + ); + // external job 在 worker 领取后才预扣泥点,running 可能先于扣费落账。 + // 只要仍有全局 active task,每轮成功轮询都推动钱包收敛;终态这一轮 + // 同时覆盖成功结算或失败退款。共享 Store 会合并并发刷新。 + onExternalTaskWalletMayHaveChanged?.(); notifyCompletedExternalTasks(refreshedTasks, { activeTaskIds: activeExternalTaskIdsRef.current, }); @@ -483,11 +507,11 @@ export function ImageCanvasTaskSidebarView({ } }; }, [ - activeExternalTaskIds, - activeExternalTaskKey, completedListLimit, filterVisibleExternalTasks, notifyCompletedExternalTasks, + onExternalTaskWalletMayHaveChanged, + walletActiveExternalTaskKey, ]); const handleTaskListScroll = useCallback( -- 2.52.0 From 6300ba55f7e71d85c6b613a056997777e658b86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 20:20:53 +0800 Subject: [PATCH 14/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dlint=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正钱包生命周期分支的 lint 问题。 --- apps/ai-game-creator-shell/tests/walletStore.test.ts | 2 +- .../platform-entry/PlatformEntryActiveFlowShell.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index 2fb483e16..64e0c2230 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -14,8 +14,8 @@ const clientApi = vi.hoisted(() => ({ vi.mock('../src/services/clientApi', () => clientApi); -import { useWalletStore } from '../src/stores/useWalletStore'; import { useAccountWallet } from '../src/features/app-shell/useAccountWallet'; +import { useWalletStore } from '../src/stores/useWalletStore'; function detailedBalance(totalPoints: number) { return { diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index cb836cc9d..6e7678ff8 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -5,9 +5,9 @@ import { useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; import { PlatformEntryFlowShellImpl } from './PlatformEntryActiveFlowShell'; import type { SelectionStage } from './platformEntryActiveTypes'; -import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; const authUiMock = vi.hoisted(() => ({ value: { -- 2.52.0 From 2bb52ef915a7bd6ff831a1dc5fad6e8f46dead35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 10:35:25 +0800 Subject: [PATCH 15/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=92=B1=E5=8C=85?= =?UTF-8?q?=E6=97=A7=E5=BF=AB=E7=85=A7=E5=90=9E=E6=8E=89=E7=BB=88=E6=80=81?= =?UTF-8?q?=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为钱包快照绑定请求发起时的 owner 生命周期与 invalidation 版本 拒绝过期普通 GET 快照覆盖余额或中止更新刷新 同步主站与 AI Game Creator 调用方及竞态回归测试 更新账户与充值基线约束 --- .../features/app-shell/useAccountWallet.ts | 14 ++-- .../tests/walletStore.test.ts | 31 +++++--- ...项目基线】当前产品与工程约束-2026-05-15.md | 2 +- .../stores/createProfileWalletStore.test.ts | 70 ++++++++++++++++--- .../src/stores/createProfileWalletStore.ts | 43 ++++++++++-- .../ImageCanvasEditorView.test.tsx | 46 +++++++----- .../usePlatformProfileCenterController.ts | 30 ++++---- 7 files changed, 176 insertions(+), 60 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 78b1c8dab..3fc25a6c5 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -22,6 +22,7 @@ export function useAccountWallet(currentUserId: string) { mudPointBalanceStatus, mudPointBalanceError, setWalletOwner, + captureWalletBalanceSnapshot, applyWalletBalanceSnapshot, onWalletBalanceMayHaveChanged, resetWalletBalance, @@ -136,7 +137,7 @@ export function useAccountWallet(currentUserId: string) { } function applyRechargeContent( - snapshotOwnerUserId: string, + walletSnapshot: ReturnType, center: ProfileRechargeCenterResponse, ) { const { @@ -145,8 +146,8 @@ export function useAccountWallet(currentUserId: string) { ...content } = center; void walletBalance; - if (nextMudPointBalance) { - applyWalletBalanceSnapshot(snapshotOwnerUserId, nextMudPointBalance); + if (nextMudPointBalance && walletSnapshot) { + applyWalletBalanceSnapshot(walletSnapshot, nextMudPointBalance); } else { void onWalletBalanceMayHaveChanged(); } @@ -155,6 +156,7 @@ export function useAccountWallet(currentUserId: string) { async function loadRechargeCenter() { const snapshotOwnerUserId = currentUserId; + const walletSnapshot = captureWalletBalanceSnapshot(snapshotOwnerUserId); const rechargeLifecycle = rechargeLifecycleRef.current; setRechargeLoading(true); setRechargeError(null); @@ -163,7 +165,7 @@ export function useAccountWallet(currentUserId: string) { if (rechargeLifecycleRef.current !== rechargeLifecycle) { return; } - applyRechargeContent(snapshotOwnerUserId, center); + applyRechargeContent(walletSnapshot, center); } catch (error) { if (rechargeLifecycleRef.current === rechargeLifecycle) { setRechargeError( @@ -307,9 +309,7 @@ export function useAccountWallet(currentUserId: string) { submittingRechargeProductId: walletUiIsVisible ? submittingRechargeProductId : null, - nativeRechargePayment: walletUiIsVisible - ? nativeRechargePayment - : null, + nativeRechargePayment: walletUiIsVisible ? nativeRechargePayment : null, setNativeRechargePayment, loadWalletLedger, openWalletLedger, diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index 64e0c2230..008963d36 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -53,6 +53,7 @@ describe('useWalletStore', () => { expect(result.current.mudPointBalanceError).toBe(''); expect(Object.keys(result.current).sort()).toEqual([ 'applyWalletBalanceSnapshot', + 'captureWalletBalanceSnapshot', 'mudPointBalance', 'mudPointBalanceError', 'mudPointBalanceStatus', @@ -70,9 +71,12 @@ describe('useWalletStore', () => { await useWalletStore.getState().onWalletBalanceMayHaveChanged(); const mudPointBalance = detailedBalance(120); + const snapshot = useWalletStore + .getState() + .captureWalletBalanceSnapshot('user-a'); useWalletStore .getState() - .applyWalletBalanceSnapshot('user-a', mudPointBalance); + .applyWalletBalanceSnapshot(snapshot!, mudPointBalance); expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready'); @@ -90,9 +94,12 @@ describe('useWalletStore', () => { const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged(); const mudPointBalance = detailedBalance(160); + const snapshot = useWalletStore + .getState() + .captureWalletBalanceSnapshot('user-a'); useWalletStore .getState() - .applyWalletBalanceSnapshot('user-a', mudPointBalance); + .applyWalletBalanceSnapshot(snapshot!, mudPointBalance); rejectRequest?.(new Error('过期请求失败')); await refresh; @@ -141,10 +148,12 @@ describe('useWalletStore', () => { mudPointBalance: detailedBalance(80), }); - const firstRefresh = - useWalletStore.getState().onWalletBalanceMayHaveChanged(); - const trailingRefresh = - useWalletStore.getState().onWalletBalanceMayHaveChanged(); + const firstRefresh = useWalletStore + .getState() + .onWalletBalanceMayHaveChanged(); + const trailingRefresh = useWalletStore + .getState() + .onWalletBalanceMayHaveChanged(); resolveFirst?.({ walletBalance: 120, mudPointBalance: detailedBalance(120), @@ -194,9 +203,12 @@ describe('useWalletStore', () => { }); test('clears immediately when the adapter switches wallet owners', () => { + const snapshot = useWalletStore + .getState() + .captureWalletBalanceSnapshot('user-a'); useWalletStore .getState() - .applyWalletBalanceSnapshot('user-a', detailedBalance(88)); + .applyWalletBalanceSnapshot(snapshot!, detailedBalance(88)); useWalletStore.getState().setWalletOwner('user-b'); @@ -208,9 +220,12 @@ describe('useWalletStore', () => { }); test('masks another owner snapshot before the owner-binding effect runs', () => { + const snapshot = useWalletStore + .getState() + .captureWalletBalanceSnapshot('user-a'); useWalletStore .getState() - .applyWalletBalanceSnapshot('user-a', detailedBalance(88)); + .applyWalletBalanceSnapshot(snapshot!, detailedBalance(88)); function WalletProbe() { const wallet = useAccountWallet('user-b'); diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 311de537d..6594b0248 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -59,7 +59,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 ## 账户与充值 -1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 +1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。普通充值中心 GET 必须在发起时捕获钱包 owner 生命周期与 invalidation 版本;回包只能结算不晚于该版本的刷新,过期快照不得覆盖余额或中止更新的终态刷新。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 2. 账户充值弹窗标题统一为“购买更多泥点”,当前版本只展示泥点商品,不展示会员页签、会员商品、购买会员或升级会员入口。底层会员数据与周期刷新能力继续保留用于存量兼容和结算,会员周期限时泥点不在当前版本前台展示。 3. 泥点默认商品固定为四档:`60 泥点 / ¥6`、`180 + 90 泥点 / ¥18`、`300 + 150 泥点 / ¥30`、`680 + 340 泥点 / ¥68`。`60` 档不加赠,后三档首次购买各加赠基础泥点的 `50%`;实际展示、下单校验和支付确认仍以后端返回的充值商品配置为准。 4. 首充加赠资格按泥点商品档位独立计算。用户买过 `points_180` 后,只影响 `points_180` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。 diff --git a/packages/shared/src/stores/createProfileWalletStore.test.ts b/packages/shared/src/stores/createProfileWalletStore.test.ts index 39502b4a9..d883685a8 100644 --- a/packages/shared/src/stores/createProfileWalletStore.test.ts +++ b/packages/shared/src/stores/createProfileWalletStore.test.ts @@ -19,7 +19,9 @@ function balance(totalPoints: number): ProfileMudPointBalance { } function center(totalPoints: number): ProfileRechargeCenterResponse { - return { mudPointBalance: balance(totalPoints) } as ProfileRechargeCenterResponse; + return { + mudPointBalance: balance(totalPoints), + } as ProfileRechargeCenterResponse; } function deferred() { @@ -91,7 +93,9 @@ describe('createProfileWalletStore', () => { store.getState().setWalletOwner('user-a'); const refresh = store.getState().onWalletBalanceMayHaveChanged(); - store.getState().applyWalletBalanceSnapshot('user-a', balance(80)); + const snapshot = store.getState().captureWalletBalanceSnapshot('user-a'); + expect(snapshot).not.toBeNull(); + store.getState().applyWalletBalanceSnapshot(snapshot!, balance(80)); pending.resolve(center(20)); await refresh; @@ -114,7 +118,8 @@ describe('createProfileWalletStore', () => { }); const store = createProfileWalletStore({ getRechargeCenter }); store.getState().setWalletOwner('user-a'); - store.getState().applyWalletBalanceSnapshot('user-a', balance(50)); + const snapshot = store.getState().captureWalletBalanceSnapshot('user-a'); + store.getState().applyWalletBalanceSnapshot(snapshot!, balance(50)); const oldRefresh = store.getState().onWalletBalanceMayHaveChanged(); store.getState().setWalletOwner('user-b'); @@ -143,7 +148,8 @@ describe('createProfileWalletStore', () => { getRechargeCenter: vi.fn().mockRejectedValue(new Error('network down')), }); store.getState().setWalletOwner('user-a'); - store.getState().applyWalletBalanceSnapshot('user-a', balance(55)); + const snapshot = store.getState().captureWalletBalanceSnapshot('user-a'); + store.getState().applyWalletBalanceSnapshot(snapshot!, balance(55)); await store.getState().onWalletBalanceMayHaveChanged(); @@ -171,14 +177,15 @@ describe('createProfileWalletStore', () => { }); }); - test('ignores a snapshot captured for a different owner', () => { + test('does not capture a snapshot for a different owner', () => { const store = createProfileWalletStore({ getRechargeCenter: vi.fn(), }); store.getState().setWalletOwner('user-b'); - store.getState().applyWalletBalanceSnapshot('user-a', balance(100)); + const snapshot = store.getState().captureWalletBalanceSnapshot('user-a'); + expect(snapshot).toBeNull(); expect(store.getState().mudPointBalance).toBeNull(); expect(store.getState().mudPointBalanceStatus).toBe('idle'); }); @@ -189,13 +196,60 @@ describe('createProfileWalletStore', () => { }); store.getState().setWalletOwner('user-a'); - store + const snapshot = store .getState() - .applyWalletBalanceSnapshot(' user-a ', balance(100)); + .captureWalletBalanceSnapshot(' user-a '); + store.getState().applyWalletBalanceSnapshot(snapshot!, balance(100)); expect(store.getState()).toMatchObject({ mudPointBalance: balance(100), mudPointBalanceStatus: 'ready', }); }); + + test('does not let an older ordinary snapshot swallow a terminal refresh', async () => { + const terminalRefresh = deferred(); + const requestSignals: Array = []; + const store = createProfileWalletStore({ + getRechargeCenter: vi.fn((signal?: AbortSignal) => { + requestSignals.push(signal); + return terminalRefresh.promise; + }), + }); + store.getState().setWalletOwner('user-a'); + const ordinarySnapshot = store + .getState() + .captureWalletBalanceSnapshot('user-a'); + + const refresh = store.getState().onWalletBalanceMayHaveChanged(); + expect( + store + .getState() + .applyWalletBalanceSnapshot(ordinarySnapshot!, balance(80)), + ).toBe(false); + expect(requestSignals[0]?.aborted).toBe(false); + + terminalRefresh.resolve(center(100)); + await refresh; + + expect(store.getState().mudPointBalance?.totalPoints).toBe(100); + }); + + test('rejects a snapshot after switching away from and back to the same owner', () => { + const store = createProfileWalletStore({ getRechargeCenter: vi.fn() }); + store.getState().setWalletOwner('user-a'); + const oldLifecycleSnapshot = store + .getState() + .captureWalletBalanceSnapshot('user-a'); + + store.getState().setWalletOwner('user-b'); + store.getState().setWalletOwner('user-a'); + + expect( + store + .getState() + .applyWalletBalanceSnapshot(oldLifecycleSnapshot!, balance(100)), + ).toBe(false); + expect(store.getState().mudPointBalance).toBeNull(); + }); }); diff --git a/packages/shared/src/stores/createProfileWalletStore.ts b/packages/shared/src/stores/createProfileWalletStore.ts index 527c5ff08..9b1f8a787 100644 --- a/packages/shared/src/stores/createProfileWalletStore.ts +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -8,19 +8,30 @@ import type { export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error'; export type ProfileWalletApi = { - getRechargeCenter(signal?: AbortSignal): Promise; + getRechargeCenter( + signal?: AbortSignal, + ): Promise; }; +export type ProfileWalletBalanceSnapshot = Readonly<{ + ownerUserId: string; + ownerVersion: number; + invalidationVersion: number; +}>; + export type ProfileWalletStore = { ownerUserId: string | null; mudPointBalance: ProfileMudPointBalance | null; mudPointBalanceStatus: MudPointBalanceStatus; mudPointBalanceError: string; setWalletOwner: (userId: string | null) => void; - applyWalletBalanceSnapshot: ( + captureWalletBalanceSnapshot: ( ownerUserId: string, + ) => ProfileWalletBalanceSnapshot | null; + applyWalletBalanceSnapshot: ( + snapshot: ProfileWalletBalanceSnapshot, balance: ProfileMudPointBalance, - ) => void; + ) => boolean; onWalletBalanceMayHaveChanged: () => Promise; resetWalletBalance: () => void; }; @@ -36,6 +47,7 @@ export function createProfileWalletStore( ): UseBoundStore> { let refreshVersion = 0; let settledRefreshVersion = 0; + let ownerVersion = 0; let requestGeneration = 0; let activeRefresh: Promise | null = null; let activeAbortController: AbortController | null = null; @@ -56,6 +68,7 @@ export function createProfileWalletStore( return; } + ownerVersion += 1; invalidateActiveRefresh(); settledRefreshVersion = refreshVersion; set({ @@ -63,22 +76,39 @@ export function createProfileWalletStore( ...EMPTY_WALLET_STATE, }); }, - applyWalletBalanceSnapshot: (ownerUserId, balance) => { + captureWalletBalanceSnapshot: (ownerUserId) => { const normalizedOwnerUserId = ownerUserId.trim(); if ( !normalizedOwnerUserId || get().ownerUserId !== normalizedOwnerUserId ) { - return; + return null; + } + + return { + ownerUserId: normalizedOwnerUserId, + ownerVersion, + invalidationVersion: refreshVersion, + }; + }, + applyWalletBalanceSnapshot: (snapshot, balance) => { + if ( + get().ownerUserId !== snapshot.ownerUserId || + ownerVersion !== snapshot.ownerVersion || + snapshot.invalidationVersion < refreshVersion || + snapshot.invalidationVersion < settledRefreshVersion + ) { + return false; } invalidateActiveRefresh(); - settledRefreshVersion = refreshVersion; + settledRefreshVersion = snapshot.invalidationVersion; set({ mudPointBalance: balance, mudPointBalanceStatus: 'ready', mudPointBalanceError: '', }); + return true; }, onWalletBalanceMayHaveChanged: () => { refreshVersion += 1; @@ -149,6 +179,7 @@ export function createProfileWalletStore( return refresh; }, resetWalletBalance: () => { + ownerVersion += 1; invalidateActiveRefresh(); settledRefreshVersion = refreshVersion; set({ ownerUserId: null, ...EMPTY_WALLET_STATE }); diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index b97b17d0c..5405b31d8 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -596,15 +596,20 @@ describe('ImageCanvasEditorView', () => { it('shows the live mud point balance in the canvas topbar when logged in', async () => { usePlatformWalletStore.getState().setWalletOwner('user-1'); - usePlatformWalletStore.getState().applyWalletBalanceSnapshot('user-1', { - totalPoints: 1234, - permanentPoints: 1000, - limitedPoints: 214, - limitedExpiresAt: '2026-07-31T16:00:00Z', - dailyFreePoints: 20, - dailyFreeResetPoints: 20, - dailyFreeResetsAt: '2026-07-12T16:00:00Z', - }); + const walletSnapshot = usePlatformWalletStore + .getState() + .captureWalletBalanceSnapshot('user-1'); + usePlatformWalletStore + .getState() + .applyWalletBalanceSnapshot(walletSnapshot!, { + totalPoints: 1234, + permanentPoints: 1000, + limitedPoints: 214, + limitedExpiresAt: '2026-07-31T16:00:00Z', + dailyFreePoints: 20, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-07-12T16:00:00Z', + }); render( { it('opens the shared wallet breakdown and ledger from the canvas topbar', async () => { const user = userEvent.setup(); usePlatformWalletStore.getState().setWalletOwner('user-1'); - usePlatformWalletStore.getState().applyWalletBalanceSnapshot('user-1', { - totalPoints: 1234, - permanentPoints: 1000, - limitedPoints: 0, - limitedExpiresAt: null, - dailyFreePoints: 234, - dailyFreeResetPoints: 20, - dailyFreeResetsAt: '2026-07-12T16:00:00Z', - }); + const walletSnapshot = usePlatformWalletStore + .getState() + .captureWalletBalanceSnapshot('user-1'); + usePlatformWalletStore + .getState() + .applyWalletBalanceSnapshot(walletSnapshot!, { + totalPoints: 1234, + permanentPoints: 1000, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 234, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-07-12T16:00:00Z', + }); render( state.applyWalletBalanceSnapshot, ); + const captureWalletBalanceSnapshot = usePlatformWalletStore( + (state) => state.captureWalletBalanceSnapshot, + ); const onWalletBalanceMayHaveChanged = usePlatformWalletStore( (state) => state.onWalletBalanceMayHaveChanged, ); @@ -402,8 +401,9 @@ export function usePlatformProfileCenterController({ setRewardCodeSuccess(null); setIsSubmittingReferralRedeem(false); }, [currentUserId]); - const rechargeCenterReadAbortControllerRef = - useRef(null); + const rechargeCenterReadAbortControllerRef = useRef( + null, + ); useEffect(() => { return () => { @@ -480,7 +480,11 @@ export function usePlatformProfileCenterController({ }, [loadWalletLedger]); const applyRechargeCenter = useCallback( - (center: ProfileRechargeCenterResponse, refreshWallet = false) => { + ( + center: ProfileRechargeCenterResponse, + refreshWallet = false, + walletSnapshot = captureWalletBalanceSnapshot(currentUserId), + ) => { if ( !currentUserId || usePlatformWalletStore.getState().ownerUserId !== currentUserId @@ -493,8 +497,8 @@ export function usePlatformProfileCenterController({ setIsLoadingRechargeCenter(false); setRechargeError(null); setRechargeCenter(center); - if (center.mudPointBalance) { - applyWalletBalanceSnapshot(currentUserId, center.mudPointBalance); + if (center.mudPointBalance && walletSnapshot) { + applyWalletBalanceSnapshot(walletSnapshot, center.mudPointBalance); } if (refreshWallet || !center.mudPointBalance) { void onWalletBalanceMayHaveChanged(); @@ -503,12 +507,14 @@ export function usePlatformProfileCenterController({ }, [ applyWalletBalanceSnapshot, + captureWalletBalanceSnapshot, currentUserId, onWalletBalanceMayHaveChanged, ], ); const loadRechargeCenter = useCallback(() => { + const walletSnapshot = captureWalletBalanceSnapshot(currentUserId); const revision = ++rechargeCenterReadRevisionRef.current; rechargeCenterReadAbortControllerRef.current?.abort(); const abortController = new AbortController(); @@ -521,7 +527,7 @@ export function usePlatformProfileCenterController({ !abortController.signal.aborted && revision === rechargeCenterReadRevisionRef.current ) { - applyRechargeCenter(center); + applyRechargeCenter(center, false, walletSnapshot); } }) .catch((error: unknown) => { @@ -544,7 +550,7 @@ export function usePlatformProfileCenterController({ setIsLoadingRechargeCenter(false); } }); - }, [applyRechargeCenter]); + }, [applyRechargeCenter, captureWalletBalanceSnapshot, currentUserId]); const refreshRechargeState = useCallback(() => { loadRechargeCenter(); -- 2.52.0 From 62005f131306dc939ec35c2830f811a6d68a9bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 11:09:59 +0800 Subject: [PATCH 16/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E9=A6=96=E6=AC=A1=E5=8A=A0=E8=BD=BD=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=90=8E=E5=81=9C=E6=AD=A2=E8=BD=AE=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为任务列表 bootstrap 增加一秒、两秒和四秒的有界退避重试 瞬时失败时保留已有任务并在恢复 active ID 后启动钱包轮询 覆盖重试恢复与重试耗尽边界 同步更新账户钱包轮询基线 --- ...项目基线】当前产品与工程约束-2026-05-15.md | 2 +- .../ImageCanvasTaskSidebarView.test.tsx | 98 +++++++++++++++ .../ImageCanvasTaskSidebarView.tsx | 116 ++++++++++-------- 3 files changed, 166 insertions(+), 50 deletions(-) diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 6594b0248..eeeba59d2 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -59,7 +59,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 ## 账户与充值 -1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。普通充值中心 GET 必须在发起时捕获钱包 owner 生命周期与 invalidation 版本;回包只能结算不晚于该版本的刷新,过期快照不得覆盖余额或中止更新的终态刷新。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 +1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。普通充值中心 GET 必须在发起时捕获钱包 owner 生命周期与 invalidation 版本;回包只能结算不晚于该版本的刷新,过期快照不得覆盖余额或中止更新的终态刷新。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;任务列表首次 bootstrap 的 active / terminal 任一读取瞬时失败时必须有界退避重试,成功取得全局 active ID 后再交给常规轮询,不能把首次空状态固化为停止钱包通知。公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 2. 账户充值弹窗标题统一为“购买更多泥点”,当前版本只展示泥点商品,不展示会员页签、会员商品、购买会员或升级会员入口。底层会员数据与周期刷新能力继续保留用于存量兼容和结算,会员周期限时泥点不在当前版本前台展示。 3. 泥点默认商品固定为四档:`60 泥点 / ¥6`、`180 + 90 泥点 / ¥18`、`300 + 150 泥点 / ¥30`、`680 + 340 泥点 / ¥68`。`60` 档不加赠,后三档首次购买各加赠基础泥点的 `50%`;实际展示、下单校验和支付确认仍以后端返回的充值商品配置为准。 4. 首充加赠资格按泥点商品档位独立计算。用户买过 `points_180` 后,只影响 `points_180` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。 diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index 028b77bf8..ec42f3bcb 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -615,6 +615,104 @@ describe('ImageCanvasTaskSidebarView', () => { } }); + it('retries a failed bootstrap and starts wallet polling from the recovered active task', async () => { + vi.useFakeTimers(); + try { + const onExternalTaskWalletMayHaveChanged = vi.fn(); + const runningTask = createExternalTask({ + jobId: 'bootstrap-recovered-task', + status: 'running', + }); + let activeRequestCount = 0; + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => { + if (options.statuses?.includes('running')) { + activeRequestCount += 1; + if (activeRequestCount === 1) { + return Promise.reject(new Error('temporary bootstrap failure')); + } + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 1, + unacknowledgedTerminalCount: 0, + updatedAtMicros: activeRequestCount, + }, + tasks: [runningTask], + }); + } + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: 0, + updatedAtMicros: activeRequestCount, + }, + tasks: [], + }); + }, + ); + + render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + await Promise.resolve(); + }); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2); + expect(activeRequestCount).toBe(3); + } finally { + vi.useRealTimers(); + } + }); + + it('stops bootstrap retries after the bounded retry schedule is exhausted', async () => { + vi.useFakeTimers(); + try { + listExternalGenerationTasksMock.mockRejectedValue( + new Error('task list unavailable'), + ); + + render( + , + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + await Promise.resolve(); + }); + + expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(8); + } finally { + vi.useRealTimers(); + } + }); + it('loads more completed tasks while scrolling and keeps the request capped', async () => { const completedTasks = Array.from({ length: 120 }, (_, index) => { const completedAt = new Date(Date.now() - index * 1000).toISOString(); diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx index 07be2f2e9..f985283d8 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -26,6 +26,7 @@ const COMPLETED_TASK_LIST_LIMIT = 20; const COMPLETED_TASK_LIST_MAX_LIMIT = 100; const ACTIVE_TASK_LIST_LIMIT = 100; const ACTIVE_TASK_POLL_INTERVAL_MS = 4000; +const TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS = [1000, 2000, 4000] as const; type TaskSidebarTab = 'active' | 'completed'; @@ -357,58 +358,75 @@ export function ImageCanvasTaskSidebarView({ useEffect(() => { let disposed = false; - const controller = new AbortController(); - Promise.all([ - listExternalGenerationTasks({ - limit: ACTIVE_TASK_LIST_LIMIT, - includeAcknowledgedTerminal: false, - statuses: ['running', 'queued'], - signal: controller.signal, - }), - listExternalGenerationTasks({ - limit: completedListLimit, - includeAcknowledgedTerminal: true, - statuses: ['completed', 'failed'], - signal: controller.signal, - }), - ]) - .then(([activeResponse, completedResponse]) => { - if (disposed) { - return; - } - const activeTasks = activeResponse.tasks.filter(isActiveExternalTask); - const visibleActiveTasks = filterVisibleExternalTasks(activeTasks); - const visibleCompletedTasks = filterVisibleExternalTasks( - completedResponse.tasks, - ); - setWalletActiveExternalTaskIds(activeTasks.map((task) => task.jobId)); - if ( - activeTasks.length > 0 || - completedResponse.tasks.some( - (task) => - isTerminalExternalTask(task) && !task.notificationAcknowledgedAt, - ) - ) { - onExternalTaskWalletMayHaveChanged?.(); - } - notifyCompletedExternalTasks(visibleCompletedTasks, { - unacknowledgedOnly: true, + let controller: AbortController | null = null; + let retryTimerId: number | null = null; + let retryIndex = 0; + const loadTaskLists = () => { + controller = new AbortController(); + Promise.all([ + listExternalGenerationTasks({ + limit: ACTIVE_TASK_LIST_LIMIT, + includeAcknowledgedTerminal: false, + statuses: ['running', 'queued'], + signal: controller.signal, + }), + listExternalGenerationTasks({ + limit: completedListLimit, + includeAcknowledgedTerminal: true, + statuses: ['completed', 'failed'], + signal: controller.signal, + }), + ]) + .then(([activeResponse, completedResponse]) => { + if (disposed) { + return; + } + const activeTasks = activeResponse.tasks.filter(isActiveExternalTask); + const visibleActiveTasks = filterVisibleExternalTasks(activeTasks); + const visibleCompletedTasks = filterVisibleExternalTasks( + completedResponse.tasks, + ); + setWalletActiveExternalTaskIds(activeTasks.map((task) => task.jobId)); + if ( + activeTasks.length > 0 || + completedResponse.tasks.some( + (task) => + isTerminalExternalTask(task) && + !task.notificationAcknowledgedAt, + ) + ) { + onExternalTaskWalletMayHaveChanged?.(); + } + notifyCompletedExternalTasks(visibleCompletedTasks, { + unacknowledgedOnly: true, + }); + setExternalTasks( + trimStoredExternalTasks( + mergeExternalTasks(visibleActiveTasks, visibleCompletedTasks), + completedListLimit, + ), + ); + }) + .catch(() => { + if ( + disposed || + retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length + ) { + return; + } + const retryDelayMs = TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS[retryIndex]; + retryIndex += 1; + retryTimerId = window.setTimeout(loadTaskLists, retryDelayMs); }); - setExternalTasks( - trimStoredExternalTasks( - mergeExternalTasks(visibleActiveTasks, visibleCompletedTasks), - completedListLimit, - ), - ); - }) - .catch(() => { - if (!disposed) { - setExternalTasks([]); - } - }); + }; + + loadTaskLists(); return () => { disposed = true; - controller.abort(); + controller?.abort(); + if (retryTimerId !== null) { + window.clearTimeout(retryTimerId); + } }; }, [ completedListLimit, -- 2.52.0 From 933f6d00569380fc17dd4260a514e4643a5d927e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 11:12:36 +0800 Subject: [PATCH 17/36] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E7=A9=BA=E8=B4=A6?= =?UTF-8?q?=E5=8D=95=E7=9A=84=E6=97=A7=E7=89=88=E4=BD=99=E9=A2=9D=E5=85=9C?= =?UTF-8?q?=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在钱包 owner 匹配时保留充值中心 legacy walletBalance 共享泥点明细仍优先于兼容展示余额 覆盖仅返回旧版余额时空账单显示 37 泥点 同步更新账户余额兼容边界 --- ...项目基线】当前产品与工程约束-2026-05-15.md | 2 +- .../PlatformEntryActiveFlowShell.test.tsx | 41 ++++++++++++++++++- .../PlatformEntryActiveFlowShell.tsx | 5 ++- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index eeeba59d2..a765d685e 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -59,7 +59,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 ## 账户与充值 -1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。普通充值中心 GET 必须在发起时捕获钱包 owner 生命周期与 invalidation 版本;回包只能结算不晚于该版本的刷新,过期快照不得覆盖余额或中止更新的终态刷新。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;任务列表首次 bootstrap 的 active / terminal 任一读取瞬时失败时必须有界退避重试,成功取得全局 active ID 后再交给常规轮询,不能把首次空状态固化为停止钱包通知。公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 +1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`;只有充值中心响应缺少 `mudPointBalance` 时,充值弹窗与空账单可以在 owner 匹配后使用同一响应的 legacy `walletBalance` 作为展示兜底,不得写回共享钱包状态。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。普通充值中心 GET 必须在发起时捕获钱包 owner 生命周期与 invalidation 版本;回包只能结算不晚于该版本的刷新,过期快照不得覆盖余额或中止更新的终态刷新。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;任务列表首次 bootstrap 的 active / terminal 任一读取瞬时失败时必须有界退避重试,成功取得全局 active ID 后再交给常规轮询,不能把首次空状态固化为停止钱包通知。公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 2. 账户充值弹窗标题统一为“购买更多泥点”,当前版本只展示泥点商品,不展示会员页签、会员商品、购买会员或升级会员入口。底层会员数据与周期刷新能力继续保留用于存量兼容和结算,会员周期限时泥点不在当前版本前台展示。 3. 泥点默认商品固定为四档:`60 泥点 / ¥6`、`180 + 90 泥点 / ¥18`、`300 + 150 泥点 / ¥30`、`680 + 340 泥点 / ¥68`。`60` 档不加赠,后三档首次购买各加赠基础泥点的 `50%`;实际展示、下单校验和支付确认仍以后端返回的充值商品配置为准。 4. 首充加赠资格按泥点商品档位独立计算。用户买过 `points_180` 后,只影响 `points_180` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。 diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index 6e7678ff8..e250797d2 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -28,6 +28,11 @@ const profileClientMock = vi.hoisted(() => ({ getPlatformProfileRechargeCenter: vi.fn(), })); +const profileCenterMock = vi.hoisted(() => ({ + isWalletLedgerOpen: false, + rechargeCenter: null as { walletBalance: number } | null, +})); + vi.mock('../auth/AuthUiContext', () => ({ useAuthUi: () => authUiMock.value, })); @@ -102,11 +107,11 @@ vi.mock('./usePlatformProfileCenterController', () => ({ isLoadingRechargeCenter: false, isLoadingWalletLedger: false, isRechargeOpen: false, - isWalletLedgerOpen: false, + isWalletLedgerOpen: profileCenterMock.isWalletLedgerOpen, loadRechargeCenter: vi.fn(), nativeWechatPayment: null, openWalletLedgerPanel: vi.fn(), - rechargeCenter: null, + rechargeCenter: profileCenterMock.rechargeCenter, rechargeError: null, rechargePaymentResult: null, setIsRechargeOpen: vi.fn(), @@ -140,6 +145,8 @@ describe('PlatformEntryActiveFlowShell', () => { usePlatformWalletStore.getState().resetWalletBalance(); profileClientMock.getPlatformProfileDashboard.mockReset(); profileClientMock.getPlatformProfileRechargeCenter.mockReset(); + profileCenterMock.isWalletLedgerOpen = false; + profileCenterMock.rechargeCenter = null; authUiMock.value.user = null; authUiMock.value.canAccessProtectedData = false; authUiMock.value.openLoginModal.mockReset(); @@ -199,6 +206,36 @@ describe('PlatformEntryActiveFlowShell', () => { expect(screen.queryByText('999')).toBeNull(); }); + it('uses the owner-matched legacy recharge balance for an empty ledger', 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({ + walletBalance: 37, + }); + profileCenterMock.isWalletLedgerOpen = true; + profileCenterMock.rechargeCenter = { walletBalance: 37 }; + + render( + , + ); + + expect(await screen.findByText('37泥点')).toBeTruthy(); + expect(screen.getByText('暂无账单记录')).toBeTruthy(); + }); + it('clears the previous wallet synchronously when the authenticated account changes', async () => { authUiMock.value.user = { id: 'user-1', diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 0e0c4b236..e40daf291 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -370,7 +370,10 @@ export function PlatformEntryFlowShellImpl({ } const isAuthenticated = Boolean(authUi?.user); - const balance = mudPointBalance?.totalPoints ?? null; + const legacyWalletBalance = walletOwnerMatchesCurrentUser + ? (profileCenter.rechargeCenter?.walletBalance ?? null) + : null; + const balance = mudPointBalance?.totalPoints ?? legacyWalletBalance; const isCreationStage = !isProfileStage && (selectionStage === 'platform' || selectionStage === 'creation-home'); -- 2.52.0 From 75130c8edf5ca0a65c6a842f40b74860907ea5d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 11:24:36 +0800 Subject: [PATCH 18/36] =?UTF-8?q?=E9=9A=94=E7=A6=BB=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=90=8E=E7=9A=84=E5=85=85=E5=80=BC=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为下单到确认和 watch 全链路绑定 owner 与账号生命周期版本 pending、confirming、二维码和提交状态仅由所属充值链更新 在 catch、finally、成功回调及清 token 前拒绝旧账号副作用 覆盖旧下单、旧确认和特殊错误不影响新账号 同步更新充值生命周期基线 --- ...项目基线】当前产品与工程约束-2026-05-15.md | 2 +- ...mProfileCenterController.recharge.test.tsx | 184 ++++++++ ...ormProfileCenterController.testSupport.tsx | 47 +- .../usePlatformProfileCenterController.ts | 430 ++++++++++++++---- 4 files changed, 579 insertions(+), 84 deletions(-) diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index a765d685e..567cd954f 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -65,7 +65,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 4. 首充加赠资格按泥点商品档位独立计算。用户买过 `points_180` 后,只影响 `points_180` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。 5. 前端不得用 `hasPointsRecharged` 统一隐藏所有泥点档位首充权益;该字段只表示账号是否发生过任一泥点充值。 6. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5` 或 `wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝。 -7. 小程序 WebView 充值使用 `wechat_mp_virtual` 调起小程序虚拟支付;微信内浏览器使用 `wechat_jsapi` 调起微信支付 JSAPI;普通 Web 使用 `wechat_native` 二维码支付,避免因移动 UA、触控能力或窄屏误入 `wechat_h5`。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。 +7. 小程序 WebView 充值使用 `wechat_mp_virtual` 调起小程序虚拟支付;微信内浏览器使用 `wechat_jsapi` 调起微信支付 JSAPI;普通 Web 使用 `wechat_native` 二维码支付,避免因移动 UA、触控能力或窄屏误入 `wechat_h5`。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。一次充值从下单、宿主 / JSAPI / H5 / Native 调起到查单确认与 watch 必须始终携带同一个 `ownerUserId + account lifecycle revision`;pending / confirming order、二维码、提交状态、错误结果、成功回调以及清 token、重新登录等认证副作用在每次写入前都必须校验该令牌,账号切换或卸载后旧链路不得再影响新账号。 8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。 9. 后台“充值商品”页继续维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。会员商品配置保留不表示当前版本开放公开购买或升级入口。 diff --git a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx index d333e061d..edc2708c6 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx @@ -3,18 +3,77 @@ import { act, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { ProfileRechargeProduct } from '../../../packages/shared/src/contracts/runtime'; import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; import { + apiClientMocks, + hostBridgeMocks, + paymentPlatformMocks, profileClientMocks, renderController, userA, + userB, } from './usePlatformProfileCenterController.testSupport'; +const rechargeProduct = { + productId: 'points-60', + kind: 'points', +} as ProfileRechargeProduct; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function mudPointBalance(totalPoints: number) { + return { + totalPoints, + permanentPoints: totalPoints, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-07T00:00:00+08:00', + }; +} + +function nativeRechargeResponse(totalPoints = 80, orderId = 'order-a') { + return { + order: { + orderId, + productTitle: '60 泥点', + amountCents: 600, + status: 'pending', + expirationCheckedAt: null, + }, + center: { + walletBalance: totalPoints, + mudPointBalance: mudPointBalance(totalPoints), + }, + wechatNativePayment: { + codeUrl: `weixin://wxpay/${orderId}`, + expiresAt: '2099-08-07T00:00:00+08:00', + }, + }; +} + describe('usePlatformProfileCenterController recharge fallback', () => { beforeEach(() => { window.history.replaceState(null, '', '/profile'); usePlatformWalletStore.getState().resetWalletBalance(); vi.clearAllMocks(); + paymentPlatformMocks.resolveProfileRechargeProductPaymentChannel.mockReturnValue( + 'wechat_native', + ); + hostBridgeMocks.getHostRuntime.mockReturnValue({ kind: 'browser' }); + profileClientMocks.watchWechatPlatformProfileRechargeOrder.mockReturnValue( + new Promise(() => undefined), + ); }); test('keeps the response legacy balance when no shared snapshot exists', async () => { @@ -36,4 +95,129 @@ describe('usePlatformProfileCenterController recharge fallback', () => { expect(result.current.rechargeCenter?.walletBalance).toBe(37); }); }); + + test('ignores an old owner order response after the account changes', async () => { + const pendingOrder = deferred>(); + profileClientMocks.createPlatformProfileRechargeOrder + .mockReturnValueOnce(pendingOrder.promise) + .mockResolvedValueOnce(nativeRechargeResponse(90, 'order-b')); + usePlatformWalletStore.getState().setWalletOwner('user-a'); + const { result, rerender } = renderController(userA); + + act(() => result.current.buyRechargeProduct(rechargeProduct)); + expect(result.current.submittingRechargeProductId).toBe('points-60'); + + act(() => { + rerender({ user: userB }); + usePlatformWalletStore.getState().setWalletOwner('user-b'); + }); + act(() => result.current.buyRechargeProduct(rechargeProduct)); + await waitFor(() => { + expect(result.current.nativeWechatPayment?.orderId).toBe('order-b'); + }); + await act(async () => { + pendingOrder.resolve(nativeRechargeResponse()); + await pendingOrder.promise; + await Promise.resolve(); + }); + + expect(result.current.submittingRechargeProductId).toBeNull(); + expect(result.current.nativeWechatPayment?.orderId).toBe('order-b'); + expect(result.current.rechargePaymentResult).toBeNull(); + expect(result.current.rechargeCenter?.walletBalance).toBe(90); + expect(usePlatformWalletStore.getState()).toMatchObject({ + ownerUserId: 'user-b', + mudPointBalance: mudPointBalance(90), + }); + }); + + test('ignores an old owner native confirmation after the account changes', async () => { + const pendingConfirmation = deferred<{ + order: ReturnType['order']; + center: ReturnType['center']; + }>(); + const onRechargeSuccess = vi.fn(); + profileClientMocks.createPlatformProfileRechargeOrder.mockResolvedValue( + nativeRechargeResponse(), + ); + profileClientMocks.confirmWechatPlatformProfileRechargeOrder.mockReturnValue( + pendingConfirmation.promise, + ); + usePlatformWalletStore.getState().setWalletOwner('user-a'); + const { result, rerender } = renderController(userA, onRechargeSuccess); + + act(() => result.current.buyRechargeProduct(rechargeProduct)); + await waitFor(() => { + expect(result.current.nativeWechatPayment?.orderId).toBe('order-a'); + }); + act(() => result.current.confirmNativeWechatPayment()); + + act(() => { + rerender({ user: userB }); + usePlatformWalletStore.getState().setWalletOwner('user-b'); + }); + await act(async () => { + pendingConfirmation.resolve({ + order: { + ...nativeRechargeResponse().order, + status: 'paid', + }, + center: nativeRechargeResponse(100).center, + }); + await pendingConfirmation.promise; + await Promise.resolve(); + }); + + expect(result.current.nativeWechatPayment).toBeNull(); + expect(result.current.rechargePaymentResult).toBeNull(); + expect(result.current.wechatRechargeOrderConfirmationState).toBeNull(); + expect(onRechargeSuccess).not.toHaveBeenCalled(); + expect(usePlatformWalletStore.getState()).toMatchObject({ + ownerUserId: 'user-b', + mudPointBalance: null, + }); + }); + + test('does not clear the new owner token from an old owner payment error', async () => { + const pendingOrder = deferred(); + paymentPlatformMocks.resolveProfileRechargeProductPaymentChannel.mockReturnValue( + 'wechat_mp_virtual', + ); + hostBridgeMocks.getHostRuntime.mockReturnValue({ + kind: 'wechat_mini_program', + }); + profileClientMocks.createPlatformProfileRechargeOrder + .mockReturnValueOnce(pendingOrder.promise) + .mockResolvedValueOnce(nativeRechargeResponse(70, 'order-b')); + usePlatformWalletStore.getState().setWalletOwner('user-a'); + const { result, rerender } = renderController(userA); + + act(() => result.current.buyRechargeProduct(rechargeProduct)); + act(() => { + rerender({ user: userB }); + usePlatformWalletStore.getState().setWalletOwner('user-b'); + }); + paymentPlatformMocks.resolveProfileRechargeProductPaymentChannel.mockReturnValue( + 'wechat_native', + ); + act(() => result.current.buyRechargeProduct(rechargeProduct)); + await waitFor(() => { + expect(result.current.nativeWechatPayment?.orderId).toBe('order-b'); + }); + await act(async () => { + pendingOrder.reject(new Error('当前登录设备不支持充值')); + await pendingOrder.promise.catch(() => undefined); + await Promise.resolve(); + }); + + expect(apiClientMocks.clearStoredAccessToken).not.toHaveBeenCalled(); + expect(hostBridgeMocks.requestHostLogin).not.toHaveBeenCalled(); + expect(result.current.rechargeError).toBeNull(); + expect(result.current.rechargePaymentResult).toBeNull(); + expect(result.current.nativeWechatPayment?.orderId).toBe('order-b'); + expect(usePlatformWalletStore.getState()).toMatchObject({ + ownerUserId: 'user-b', + mudPointBalance: mudPointBalance(70), + }); + }); }); diff --git a/src/components/platform-entry/usePlatformProfileCenterController.testSupport.tsx b/src/components/platform-entry/usePlatformProfileCenterController.testSupport.tsx index 77fc1b13c..a66a341cd 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.testSupport.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.testSupport.tsx @@ -16,11 +16,52 @@ const profileClientMocks = vi.hoisted(() => ({ watchWechatPlatformProfileRechargeOrder: vi.fn(), })); -vi.mock('../../services/platform-entry/platformProfileClient', () => - profileClientMocks, +const apiClientMocks = vi.hoisted(() => ({ + clearStoredAccessToken: vi.fn(), +})); + +const hostBridgeMocks = vi.hoisted(() => ({ + getHostRuntime: vi.fn(() => ({ kind: 'browser' })), + requestHostLogin: vi.fn(), + requestHostPayment: vi.fn(), +})); + +const paymentPlatformMocks = vi.hoisted(() => ({ + resolveProfileRechargeProductPaymentChannel: vi.fn(() => 'wechat_native'), +})); + +vi.mock( + '../../services/platform-entry/platformProfileClient', + () => profileClientMocks, ); -export { profileClientMocks }; +vi.mock('../../services/apiClient', async () => { + const actual = await vi.importActual< + typeof import('../../services/apiClient') + >('../../services/apiClient'); + return { ...actual, ...apiClientMocks }; +}); + +vi.mock('../../services/host-bridge/hostBridge', async () => { + const actual = await vi.importActual< + typeof import('../../services/host-bridge/hostBridge') + >('../../services/host-bridge/hostBridge'); + return { ...actual, ...hostBridgeMocks }; +}); + +vi.mock('../../services/payment/paymentPlatform', async () => { + const actual = await vi.importActual< + typeof import('../../services/payment/paymentPlatform') + >('../../services/payment/paymentPlatform'); + return { ...actual, ...paymentPlatformMocks }; +}); + +export { + apiClientMocks, + hostBridgeMocks, + paymentPlatformMocks, + profileClientMocks, +}; import { usePlatformProfileCenterController } from './usePlatformProfileCenterController'; diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 6711ce8c3..517a3e0c0 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -76,6 +76,16 @@ export type WechatRechargeOrderConfirmationState = { export type NativeWechatPaymentState = PlatformProfileRechargeNativePaymentState; +type AccountLifecycle = Readonly<{ + ownerUserId: string; + revision: number; +}>; + +type WechatRechargeOrderLifecycle = Readonly<{ + orderId: string; + account: AccountLifecycle; +}>; + function isWechatJsapiMissingIdentityError(error: unknown) { return ( error instanceof Error && @@ -348,14 +358,52 @@ export function usePlatformProfileCenterController({ const [referralSuccess, setReferralSuccess] = useState(null); const { copyState: inviteCopyState, copyText: copyInviteText } = useCopyFeedback(); - const pendingWechatRechargeOrderIdRef = useRef(null); - const confirmingWechatRechargeOrderIdRef = useRef(null); + const pendingWechatRechargeOrderRef = + useRef(null); + const confirmingWechatRechargeOrderRef = + useRef(null); const rechargeCenterReadRevisionRef = useRef(0); const accountLifecycleRevisionRef = useRef(0); const walletLedgerReadRevisionRef = useRef(0); const currentUserId = currentUser?.id ?? ''; const currentUserIdRef = useRef(currentUserId); currentUserIdRef.current = currentUserId; + const captureAccountLifecycle = useCallback( + (): AccountLifecycle => ({ + ownerUserId: currentUserId, + revision: accountLifecycleRevisionRef.current, + }), + [currentUserId], + ); + const isAccountLifecycleCurrent = useCallback( + (account: AccountLifecycle) => + account.revision === accountLifecycleRevisionRef.current && + account.ownerUserId === currentUserIdRef.current, + [], + ); + const isRechargeOrderCurrent = useCallback( + ( + order: WechatRechargeOrderLifecycle | null | undefined, + ): order is WechatRechargeOrderLifecycle => + Boolean(order) && isAccountLifecycleCurrent(order!.account), + [isAccountLifecycleCurrent], + ); + const isSameRechargeOrder = useCallback( + ( + left: WechatRechargeOrderLifecycle | null | undefined, + right: WechatRechargeOrderLifecycle | null | undefined, + ) => { + if (!left || !right) { + return false; + } + return ( + left.orderId === right.orderId && + left.account.ownerUserId === right.account.ownerUserId && + left.account.revision === right.account.revision + ); + }, + [], + ); const walletOwnerUserId = usePlatformWalletStore( (state) => state.ownerUserId, ); @@ -381,8 +429,8 @@ export function usePlatformProfileCenterController({ rechargeCenterReadRevisionRef.current += 1; accountLifecycleRevisionRef.current += 1; walletLedgerReadRevisionRef.current += 1; - pendingWechatRechargeOrderIdRef.current = null; - confirmingWechatRechargeOrderIdRef.current = null; + pendingWechatRechargeOrderRef.current = null; + confirmingWechatRechargeOrderRef.current = null; setRechargeCenter(null); setIsLoadingRechargeCenter(false); setRechargeError(null); @@ -408,6 +456,7 @@ export function usePlatformProfileCenterController({ useEffect(() => { return () => { rechargeCenterReadRevisionRef.current += 1; + accountLifecycleRevisionRef.current += 1; rechargeCenterReadAbortControllerRef.current?.abort(); rechargeCenterReadAbortControllerRef.current = null; }; @@ -482,12 +531,13 @@ export function usePlatformProfileCenterController({ const applyRechargeCenter = useCallback( ( center: ProfileRechargeCenterResponse, + account: AccountLifecycle, refreshWallet = false, - walletSnapshot = captureWalletBalanceSnapshot(currentUserId), + walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId), ) => { if ( - !currentUserId || - usePlatformWalletStore.getState().ownerUserId !== currentUserId + !isAccountLifecycleCurrent(account) || + usePlatformWalletStore.getState().ownerUserId !== account.ownerUserId ) { return false; } @@ -508,13 +558,14 @@ export function usePlatformProfileCenterController({ [ applyWalletBalanceSnapshot, captureWalletBalanceSnapshot, - currentUserId, + isAccountLifecycleCurrent, onWalletBalanceMayHaveChanged, ], ); const loadRechargeCenter = useCallback(() => { - const walletSnapshot = captureWalletBalanceSnapshot(currentUserId); + const account = captureAccountLifecycle(); + const walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId); const revision = ++rechargeCenterReadRevisionRef.current; rechargeCenterReadAbortControllerRef.current?.abort(); const abortController = new AbortController(); @@ -525,15 +576,17 @@ export function usePlatformProfileCenterController({ .then((center) => { if ( !abortController.signal.aborted && - revision === rechargeCenterReadRevisionRef.current + revision === rechargeCenterReadRevisionRef.current && + isAccountLifecycleCurrent(account) ) { - applyRechargeCenter(center, false, walletSnapshot); + applyRechargeCenter(center, account, false, walletSnapshot); } }) .catch((error: unknown) => { if ( abortController.signal.aborted || - revision !== rechargeCenterReadRevisionRef.current + revision !== rechargeCenterReadRevisionRef.current || + !isAccountLifecycleCurrent(account) ) { return; } @@ -546,20 +599,34 @@ export function usePlatformProfileCenterController({ if (rechargeCenterReadAbortControllerRef.current === abortController) { rechargeCenterReadAbortControllerRef.current = null; } - if (revision === rechargeCenterReadRevisionRef.current) { + if ( + revision === rechargeCenterReadRevisionRef.current && + isAccountLifecycleCurrent(account) + ) { setIsLoadingRechargeCenter(false); } }); - }, [applyRechargeCenter, captureWalletBalanceSnapshot, currentUserId]); + }, [ + applyRechargeCenter, + captureAccountLifecycle, + captureWalletBalanceSnapshot, + isAccountLifecycleCurrent, + ]); - const refreshRechargeState = useCallback(() => { - loadRechargeCenter(); - setSubmittingRechargeProductId(null); - pendingWechatRechargeOrderIdRef.current = null; - confirmingWechatRechargeOrderIdRef.current = null; - setWechatRechargeOrderConfirmationState(null); - setNativeWechatPayment(null); - }, [loadRechargeCenter]); + const refreshRechargeState = useCallback( + (account: AccountLifecycle) => { + if (!isAccountLifecycleCurrent(account)) { + return; + } + loadRechargeCenter(); + setSubmittingRechargeProductId(null); + pendingWechatRechargeOrderRef.current = null; + confirmingWechatRechargeOrderRef.current = null; + setWechatRechargeOrderConfirmationState(null); + setNativeWechatPayment(null); + }, + [isAccountLifecycleCurrent, loadRechargeCenter], + ); const handleWechatPayResult = useCallback(() => { const payResult = readWechatPayResultFromHash(); @@ -567,38 +634,62 @@ export function usePlatformProfileCenterController({ return false; } + const pendingOrder = pendingWechatRechargeOrderRef.current; + if (pendingOrder && !isRechargeOrderCurrent(pendingOrder)) { + return false; + } if ( - pendingWechatRechargeOrderIdRef.current && + pendingOrder && payResult.orderId && - payResult.orderId !== pendingWechatRechargeOrderIdRef.current + payResult.orderId !== pendingOrder.orderId ) { return false; } + const account = pendingOrder?.account ?? captureAccountLifecycle(); + if (!account.ownerUserId || !isAccountLifecycleCurrent(account)) { + return false; + } + if (payResult.status === 'success') { - const orderId = - payResult.orderId || pendingWechatRechargeOrderIdRef.current; + const orderId = payResult.orderId || pendingOrder?.orderId; if (!orderId) { clearWechatPayResultHash(); return true; } - if (confirmingWechatRechargeOrderIdRef.current === orderId) { + const order = pendingOrder ?? { orderId, account }; + if ( + isSameRechargeOrder(confirmingWechatRechargeOrderRef.current, order) + ) { clearWechatPayResultHash(); return true; } - confirmingWechatRechargeOrderIdRef.current = orderId; + confirmingWechatRechargeOrderRef.current = order; setWechatRechargeOrderConfirmationState({ orderId }); setSubmittingRechargeProductId(null); setRechargePaymentResult(null); void confirmWechatRechargeOrderUntilSettled(orderId) .then((response) => { - const result = buildRechargePaymentResultForOrder(response.order); - const isPaid = result.kind === 'success'; - if (!applyRechargeCenter(response.center, true)) { + if ( + !isRechargeOrderCurrent(order) || + !isSameRechargeOrder( + confirmingWechatRechargeOrderRef.current, + order, + ) + ) { return; } - pendingWechatRechargeOrderIdRef.current = null; - confirmingWechatRechargeOrderIdRef.current = null; + const result = buildRechargePaymentResultForOrder(response.order); + const isPaid = result.kind === 'success'; + if (!applyRechargeCenter(response.center, account, true)) { + return; + } + if ( + isSameRechargeOrder(pendingWechatRechargeOrderRef.current, order) + ) { + pendingWechatRechargeOrderRef.current = null; + } + confirmingWechatRechargeOrderRef.current = null; setWechatRechargeOrderConfirmationState(null); setRechargePaymentResult(result); if (isPaid) { @@ -607,7 +698,16 @@ export function usePlatformProfileCenterController({ clearWechatPayResultHash(); }) .catch(() => { - confirmingWechatRechargeOrderIdRef.current = null; + if ( + !isRechargeOrderCurrent(order) || + !isSameRechargeOrder( + confirmingWechatRechargeOrderRef.current, + order, + ) + ) { + return; + } + confirmingWechatRechargeOrderRef.current = null; setWechatRechargeOrderConfirmationState(null); setRechargePaymentResult({ kind: 'pending', @@ -623,7 +723,7 @@ export function usePlatformProfileCenterController({ message: '本次没有扣款,泥点余额未发生变化。', }); setWechatRechargeOrderConfirmationState(null); - refreshRechargeState(); + refreshRechargeState(account); } else { const detail = payResult.errorMessage ? `微信返回:${payResult.errorMessage}` @@ -634,12 +734,20 @@ export function usePlatformProfileCenterController({ message: detail, }); setWechatRechargeOrderConfirmationState(null); - refreshRechargeState(); + refreshRechargeState(account); } clearWechatPayResultHash(); return true; - }, [applyRechargeCenter, onRechargeSuccess, refreshRechargeState]); + }, [ + applyRechargeCenter, + captureAccountLifecycle, + isAccountLifecycleCurrent, + isRechargeOrderCurrent, + isSameRechargeOrder, + onRechargeSuccess, + refreshRechargeState, + ]); const pollWechatPayResultFromHash = useCallback( () => handleWechatPayResult(), @@ -651,23 +759,32 @@ export function usePlatformProfileCenterController({ return false; } - const orderId = pendingWechatRechargeOrderIdRef.current; - if (!orderId || confirmingWechatRechargeOrderIdRef.current === orderId) { + const order = pendingWechatRechargeOrderRef.current; + if ( + !isRechargeOrderCurrent(order) || + isSameRechargeOrder(confirmingWechatRechargeOrderRef.current, order) + ) { return false; } - confirmingWechatRechargeOrderIdRef.current = orderId; - setWechatRechargeOrderConfirmationState({ orderId }); + confirmingWechatRechargeOrderRef.current = order; + setWechatRechargeOrderConfirmationState({ orderId: order.orderId }); setRechargePaymentResult(null); - void confirmWechatRechargeOrderUntilSettled(orderId) + void confirmWechatRechargeOrderUntilSettled(order.orderId) .then((response) => { - const result = buildRechargePaymentResultForOrder(response.order); - const isPaid = result.kind === 'success'; - if (!applyRechargeCenter(response.center, true)) { + if ( + !isRechargeOrderCurrent(order) || + !isSameRechargeOrder(confirmingWechatRechargeOrderRef.current, order) + ) { return; } - pendingWechatRechargeOrderIdRef.current = null; - confirmingWechatRechargeOrderIdRef.current = null; + const result = buildRechargePaymentResultForOrder(response.order); + const isPaid = result.kind === 'success'; + if (!applyRechargeCenter(response.center, order.account, true)) { + return; + } + pendingWechatRechargeOrderRef.current = null; + confirmingWechatRechargeOrderRef.current = null; setWechatRechargeOrderConfirmationState(null); setSubmittingRechargeProductId(null); setRechargePaymentResult(result); @@ -676,7 +793,13 @@ export function usePlatformProfileCenterController({ } }) .catch(() => { - confirmingWechatRechargeOrderIdRef.current = null; + if ( + !isRechargeOrderCurrent(order) || + !isSameRechargeOrder(confirmingWechatRechargeOrderRef.current, order) + ) { + return; + } + confirmingWechatRechargeOrderRef.current = null; setWechatRechargeOrderConfirmationState(null); setRechargePaymentResult({ kind: 'pending', @@ -685,7 +808,13 @@ export function usePlatformProfileCenterController({ }); }); return true; - }, [applyRechargeCenter, nativeWechatPayment, onRechargeSuccess]); + }, [ + applyRechargeCenter, + isRechargeOrderCurrent, + isSameRechargeOrder, + nativeWechatPayment, + onRechargeSuccess, + ]); const openRechargeModal = useCallback(() => { if (!currentUser) { @@ -717,16 +846,30 @@ export function usePlatformProfileCenterController({ if (current?.isConfirming) { return current; } - pendingWechatRechargeOrderIdRef.current = null; + const pendingOrder = pendingWechatRechargeOrderRef.current; + if ( + current && + pendingOrder?.orderId === current.orderId && + isRechargeOrderCurrent(pendingOrder) + ) { + pendingWechatRechargeOrderRef.current = null; + } return null; }); - }, []); + }, [isRechargeOrderCurrent]); const buyRechargeProduct = useCallback( (product: ProfileRechargeProduct) => { - if (submittingRechargeProductId) { + const account = captureAccountLifecycle(); + if ( + submittingRechargeProductId || + !account.ownerUserId || + !isAccountLifecycleCurrent(account) + ) { return; } + const walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId); + let createdOrder: WechatRechargeOrderLifecycle | null = null; const paymentChannel = resolveProfileRechargeProductPaymentChannel( { kind: product.kind }, @@ -739,23 +882,50 @@ export function usePlatformProfileCenterController({ setNativeWechatPayment(null); void createPlatformProfileRechargeOrder(product.productId, paymentChannel) .then(async (response) => { + if (!isAccountLifecycleCurrent(account)) { + return; + } + const order: WechatRechargeOrderLifecycle = { + orderId: response.order.orderId, + account, + }; + createdOrder = order; + pendingWechatRechargeOrderRef.current = order; + if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) { - pendingWechatRechargeOrderIdRef.current = response.order.orderId; - if (!applyRechargeCenter(response.center, true)) { + if ( + !applyRechargeCenter( + response.center, + account, + true, + walletSnapshot, + ) || + !isRechargeOrderCurrent(order) + ) { return; } const paymentHandled = await requestHostPayment({ payload: response.wechatMiniProgramPayParams, orderId: response.order.orderId, }); + if (!isRechargeOrderCurrent(order)) { + return; + } if (!paymentHandled) { throw new Error('请在微信小程序内完成支付'); } return; } if (paymentChannel === WECHAT_JSAPI_PAYMENT_CHANNEL) { - pendingWechatRechargeOrderIdRef.current = response.order.orderId; - if (!applyRechargeCenter(response.center, true)) { + if ( + !applyRechargeCenter( + response.center, + account, + true, + walletSnapshot, + ) || + !isRechargeOrderCurrent(order) + ) { return; } setRechargePaymentResult({ @@ -766,6 +936,9 @@ export function usePlatformProfileCenterController({ await requestWechatJsapiPayment( response.wechatMiniProgramPayParams, ); + if (!isRechargeOrderCurrent(order)) { + return; + } setRechargePaymentResult({ kind: 'pending', title: '支付处理中', @@ -773,22 +946,37 @@ export function usePlatformProfileCenterController({ }); void confirmWechatRechargeOrderUntilSettled(response.order.orderId) .then((confirmResponse) => { + if (!isRechargeOrderCurrent(order)) { + return; + } const result = buildRechargePaymentResultForOrder( confirmResponse.order, ); const isPaid = result.kind === 'success'; - if (!applyRechargeCenter(confirmResponse.center, true)) { + if ( + !applyRechargeCenter(confirmResponse.center, account, true) + ) { return; } setRechargePaymentResult(result); if (result.kind !== 'pending') { - pendingWechatRechargeOrderIdRef.current = null; + if ( + isSameRechargeOrder( + pendingWechatRechargeOrderRef.current, + order, + ) + ) { + pendingWechatRechargeOrderRef.current = null; + } } if (isPaid) { void onRechargeSuccess?.(); } }) .catch(() => { + if (!isRechargeOrderCurrent(order)) { + return; + } setRechargePaymentResult({ kind: 'pending', title: '等待微信确认', @@ -802,8 +990,15 @@ export function usePlatformProfileCenterController({ if (!h5Url) { throw new Error('微信 H5 支付链接生成失败'); } - pendingWechatRechargeOrderIdRef.current = response.order.orderId; - if (!applyRechargeCenter(response.center, true)) { + if ( + !applyRechargeCenter( + response.center, + account, + true, + walletSnapshot, + ) || + !isRechargeOrderCurrent(order) + ) { return; } setRechargePaymentResult({ @@ -811,6 +1006,9 @@ export function usePlatformProfileCenterController({ title: '正在打开微信支付', message: '完成支付后返回页面确认到账状态。', }); + if (!isRechargeOrderCurrent(order)) { + return; + } await redirectToPaymentUrl(h5Url); return; } @@ -821,8 +1019,15 @@ export function usePlatformProfileCenterController({ if (!wechatNativePayment || !codeUrl || !expiresAt) { throw new Error('微信 Native 支付链接生成失败'); } - pendingWechatRechargeOrderIdRef.current = response.order.orderId; - if (!applyRechargeCenter(response.center, true)) { + if ( + !applyRechargeCenter( + response.center, + account, + true, + walletSnapshot, + ) || + !isRechargeOrderCurrent(order) + ) { return; } setNativeWechatPayment({ @@ -841,7 +1046,18 @@ export function usePlatformProfileCenterController({ throw new Error('充值支付渠道无效'); }) .catch((error: unknown) => { - pendingWechatRechargeOrderIdRef.current = null; + if (!isAccountLifecycleCurrent(account)) { + return; + } + if ( + createdOrder && + isSameRechargeOrder( + pendingWechatRechargeOrderRef.current, + createdOrder, + ) + ) { + pendingWechatRechargeOrderRef.current = null; + } setNativeWechatPayment(null); if ( paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL && @@ -857,6 +1073,9 @@ export function usePlatformProfileCenterController({ message: '正在打开微信小程序登录,请重新登录后再支付。', }); void requestHostLogin().catch((miniProgramLoginError: unknown) => { + if (!isAccountLifecycleCurrent(account)) { + return; + } setRechargePaymentResult(null); setRechargeError( miniProgramLoginError instanceof Error @@ -877,6 +1096,9 @@ export function usePlatformProfileCenterController({ message: '正在跳转微信授权,授权后请重新发起支付。', }); void startWechatBind().catch((wechatLoginError: unknown) => { + if (!isAccountLifecycleCurrent(account)) { + return; + } setRechargePaymentResult(null); setRechargeError( wechatLoginError instanceof Error @@ -890,13 +1112,29 @@ export function usePlatformProfileCenterController({ setSubmittingRechargeProductId(null); }); }, - [applyRechargeCenter, onRechargeSuccess, submittingRechargeProductId], + [ + applyRechargeCenter, + captureAccountLifecycle, + captureWalletBalanceSnapshot, + isAccountLifecycleCurrent, + isRechargeOrderCurrent, + isSameRechargeOrder, + onRechargeSuccess, + submittingRechargeProductId, + ], ); const confirmNativeWechatPayment = useCallback(() => { if (!nativeWechatPayment || nativeWechatPayment.isConfirming) { return; } + const order = pendingWechatRechargeOrderRef.current; + if ( + !isRechargeOrderCurrent(order) || + order.orderId !== nativeWechatPayment.orderId + ) { + return; + } setNativeWechatPayment((current) => current && current.orderId === nativeWechatPayment.orderId @@ -905,20 +1143,21 @@ export function usePlatformProfileCenterController({ ); void confirmWechatRechargeOrderQuickly(nativeWechatPayment.orderId) .then((response) => { - if ( - pendingWechatRechargeOrderIdRef.current !== - nativeWechatPayment.orderId - ) { + if (!isRechargeOrderCurrent(order)) { return; } const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; - if (!applyRechargeCenter(response.center, true)) { + if (!applyRechargeCenter(response.center, order.account, true)) { return; } if (result.kind !== 'pending') { setNativeWechatPayment(null); - pendingWechatRechargeOrderIdRef.current = null; + if ( + isSameRechargeOrder(pendingWechatRechargeOrderRef.current, order) + ) { + pendingWechatRechargeOrderRef.current = null; + } setRechargePaymentResult(result); if (isPaid) { void onRechargeSuccess?.(); @@ -936,6 +1175,9 @@ export function usePlatformProfileCenterController({ } }) .catch(() => { + if (!isRechargeOrderCurrent(order)) { + return; + } setNativeWechatPayment((current) => current && current.orderId === nativeWechatPayment.orderId ? { @@ -946,20 +1188,40 @@ export function usePlatformProfileCenterController({ : current, ); }) - .finally(() => setSubmittingRechargeProductId(null)); - }, [applyRechargeCenter, nativeWechatPayment, onRechargeSuccess]); + .finally(() => { + if (isRechargeOrderCurrent(order)) { + setSubmittingRechargeProductId(null); + } + }); + }, [ + applyRechargeCenter, + isRechargeOrderCurrent, + isSameRechargeOrder, + nativeWechatPayment, + onRechargeSuccess, + ]); useEffect(() => { const orderId = nativeWechatPayment?.orderId; const expiresAtMs = Date.parse(nativeWechatPayment?.expiresAt ?? ''); - if (!orderId || !Number.isFinite(expiresAtMs)) { + const order = pendingWechatRechargeOrderRef.current; + if ( + !orderId || + !Number.isFinite(expiresAtMs) || + !isRechargeOrderCurrent(order) || + order.orderId !== orderId + ) { return undefined; } let cancelled = false; const abortController = new AbortController(); const watchUntilSettled = async () => { - while (!cancelled && Date.now() < expiresAtMs) { + while ( + !cancelled && + Date.now() < expiresAtMs && + isRechargeOrderCurrent(order) + ) { try { const response = await watchWechatPlatformProfileRechargeOrder( orderId, @@ -970,13 +1232,13 @@ export function usePlatformProfileCenterController({ if ( cancelled || !response || - pendingWechatRechargeOrderIdRef.current !== orderId + !isSameRechargeOrder(pendingWechatRechargeOrderRef.current, order) ) { return; } const result = buildRechargePaymentResultForOrder(response.order); - if (!applyRechargeCenter(response.center, true)) { + if (!applyRechargeCenter(response.center, order.account, true)) { return; } if (result.kind === 'pending') { @@ -984,9 +1246,11 @@ export function usePlatformProfileCenterController({ continue; } - pendingWechatRechargeOrderIdRef.current = null; - if (confirmingWechatRechargeOrderIdRef.current === orderId) { - confirmingWechatRechargeOrderIdRef.current = null; + pendingWechatRechargeOrderRef.current = null; + if ( + isSameRechargeOrder(confirmingWechatRechargeOrderRef.current, order) + ) { + confirmingWechatRechargeOrderRef.current = null; } setNativeWechatPayment((current) => current?.orderId === orderId ? null : current, @@ -998,7 +1262,11 @@ export function usePlatformProfileCenterController({ } return; } catch { - if (cancelled || abortController.signal.aborted) { + if ( + cancelled || + abortController.signal.aborted || + !isRechargeOrderCurrent(order) + ) { return; } } @@ -1016,6 +1284,8 @@ export function usePlatformProfileCenterController({ nativeWechatPayment?.expiresAt, nativeWechatPayment?.orderId, applyRechargeCenter, + isRechargeOrderCurrent, + isSameRechargeOrder, onRechargeSuccess, ]); -- 2.52.0 From c8e89cef02faf3d27c12fde713c7c462fd77a4ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 12:04:19 +0800 Subject: [PATCH 19/36] =?UTF-8?q?=E5=BD=92=E4=B8=80=E5=8C=96=E9=92=B1?= =?UTF-8?q?=E5=8C=85=E5=BF=AB=E7=85=A7=E4=B8=8E=E5=85=85=E5=80=BC=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 绑定 snapshot 至 owner lifecycle。 --- .../src/features/app-shell/useAccountWallet.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 3fc25a6c5..07a633e5f 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -198,7 +198,7 @@ export function useAccountWallet(currentUserId: string) { return; } const rechargeLifecycle = rechargeLifecycleRef.current; - const snapshotOwnerUserId = currentUserId; + const walletSnapshot = captureWalletBalanceSnapshot(currentUserId); setSubmittingRechargeProductId(product.productId); setRechargeError(null); try { @@ -208,7 +208,7 @@ export function useAccountWallet(currentUserId: string) { if (rechargeLifecycleRef.current !== rechargeLifecycle) { return; } - applyRechargeContent(snapshotOwnerUserId, response.center); + applyRechargeContent(walletSnapshot, response.center); const nativePayment = response.wechatNativePayment; const codeUrl = nativePayment?.codeUrl?.trim(); const expiresAt = nativePayment?.expiresAt?.trim(); @@ -239,7 +239,7 @@ export function useAccountWallet(currentUserId: string) { return; } const rechargeLifecycle = rechargeLifecycleRef.current; - const snapshotOwnerUserId = currentUserId; + const walletSnapshot = captureWalletBalanceSnapshot(currentUserId); const orderId = nativeRechargePayment.orderId; setNativeRechargePayment((current) => current?.orderId === orderId @@ -251,7 +251,7 @@ export function useAccountWallet(currentUserId: string) { if (rechargeLifecycleRef.current !== rechargeLifecycle) { return; } - applyRechargeContent(snapshotOwnerUserId, response.center); + applyRechargeContent(walletSnapshot, response.center); if (response.order.status === 'paid') { setNativeRechargePayment(null); void onWalletBalanceMayHaveChanged(); -- 2.52.0 From fe63c286904546e4bfb18a696b704d03465deb19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 14:52:16 +0800 Subject: [PATCH 20/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E9=87=8D=E8=AF=95=E8=AF=B7=E6=B1=82=E6=B3=84?= =?UTF-8?q?=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在任一 bootstrap 分支失败时中止当前轮全部请求。 补充失败分支悬挂、重试后卸载的 AbortSignal 回归测试。 --- .../ImageCanvasTaskSidebarView.test.tsx | 65 ++++++++++++++++++- .../ImageCanvasTaskSidebarView.tsx | 18 +++-- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index ec42f3bcb..ec3a6345b 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -625,7 +625,9 @@ describe('ImageCanvasTaskSidebarView', () => { }); let activeRequestCount = 0; listExternalGenerationTasksMock.mockImplementation( - (options: Parameters[0] = {}) => { + ( + options: Parameters[0] = {}, + ): ReturnType => { if (options.statuses?.includes('running')) { activeRequestCount += 1; if (activeRequestCount === 1) { @@ -713,6 +715,67 @@ describe('ImageCanvasTaskSidebarView', () => { } }); + it('aborts every bootstrap attempt when one branch fails and after retry unmounts', async () => { + vi.useFakeTimers(); + try { + const requestSignals: AbortSignal[] = []; + let activeRequestCount = 0; + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => { + const signal = options.signal; + if (!signal) { + return Promise.reject(new Error('missing abort signal')); + } + requestSignals.push(signal); + if (options.statuses?.includes('running')) { + activeRequestCount += 1; + if (activeRequestCount === 1) { + return Promise.reject(new Error('active bootstrap failed')); + } + } + return new Promise< + Awaited> + >((_, reject) => { + signal.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ); + }); + }, + ); + + const { unmount } = render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(requestSignals).toHaveLength(2); + expect(requestSignals[0]).toBe(requestSignals[1]); + expect(requestSignals[0]!.aborted).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + await Promise.resolve(); + }); + expect(requestSignals).toHaveLength(4); + expect(requestSignals[2]).toBe(requestSignals[3]); + expect(requestSignals[2]!.aborted).toBe(false); + + unmount(); + expect(requestSignals[2]!.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it('loads more completed tasks while scrolling and keeps the request capped', async () => { const completedTasks = Array.from({ length: 120 }, (_, index) => { const completedAt = new Date(Date.now() - index * 1000).toISOString(); diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx index f985283d8..835fb1c5a 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -362,23 +362,24 @@ export function ImageCanvasTaskSidebarView({ let retryTimerId: number | null = null; let retryIndex = 0; const loadTaskLists = () => { - controller = new AbortController(); + const attemptController = new AbortController(); + controller = attemptController; Promise.all([ listExternalGenerationTasks({ limit: ACTIVE_TASK_LIST_LIMIT, includeAcknowledgedTerminal: false, statuses: ['running', 'queued'], - signal: controller.signal, + signal: attemptController.signal, }), listExternalGenerationTasks({ limit: completedListLimit, includeAcknowledgedTerminal: true, statuses: ['completed', 'failed'], - signal: controller.signal, + signal: attemptController.signal, }), ]) .then(([activeResponse, completedResponse]) => { - if (disposed) { + if (disposed || attemptController.signal.aborted) { return; } const activeTasks = activeResponse.tasks.filter(isActiveExternalTask); @@ -408,6 +409,10 @@ export function ImageCanvasTaskSidebarView({ ); }) .catch(() => { + attemptController.abort(); + if (controller === attemptController) { + controller = null; + } if ( disposed || retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length @@ -417,6 +422,11 @@ export function ImageCanvasTaskSidebarView({ const retryDelayMs = TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS[retryIndex]; retryIndex += 1; retryTimerId = window.setTimeout(loadTaskLists, retryDelayMs); + }) + .finally(() => { + if (controller === attemptController) { + controller = null; + } }); }; -- 2.52.0 From df1071848f73988de419f963d47f628f2f7ee4ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 15:13:37 +0800 Subject: [PATCH 21/36] =?UTF-8?q?=E4=B8=AD=E6=AD=A2=E8=B7=A8=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=BE=AE=E4=BF=A1=E5=85=85=E5=80=BC=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为充值订单绑定 owner、账号 revision 与同一 AbortController。 在延迟确认、确认请求和 SSE watch 前后校验生命周期并透传 signal。 账号切换和卸载先中止旧链,再清理订单状态与支付回调。 补充 fake timer 回归并记录并发异步生命周期约束。 --- docs/project-memory/shared-memory/pitfalls.md | 12 + ...mProfileCenterController.recharge.test.tsx | 56 +++++ .../usePlatformProfileCenterController.ts | 219 +++++++++++++++--- 3 files changed, 257 insertions(+), 30 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index be3961396..dbf7a6230 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -61,6 +61,7 @@ - 处理:旧 action 继续禁止重放或伪造 observation;旧 child 与父 Run 先真实终态。新 Supervisor continuation 仅扫描同 Session、同 source、同有效任务合同的历史根 Run,并要求对应 ready-task 同时存在 `failed / needs-reconciliation` 记录、最终 `cancelled` 记录和 durable cancel tombstone,才把当前 manifest 的同一 failed 节点恢复为 pending,让 scheduler 创建新 child Run。manifest 的读取、筛选、child 证据重验和写回放在同一项目写锁内;每个 task journal 只读取一次并按 parent Run 建索引。较新的无 child Run 默认阻断旧凭证,只有其 root journal 精确证明为旧 failed Graph 在进入 scheduler 前即失败时才允许向前查找;scheduler 自身失败不得被当成该兼容场景。 - 验证:构造 reconciliation child、人工 cancel tombstone、failed manifest 和终态父 Run,证明同源 continuation 只重排该节点;并列普通 failed 节点保持 failed,完成合同继续继承原任务 SHA 与项目 baseline,旧 pending action 不恢复。追加覆盖“旧 failed Graph 未调度”的中间 Run 可以跨过,而较新的 scheduler failure 即使没有 child journal 也会阻断更老 tombstone。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs`。 + ## `timeout_at` 不能替代显式的预算耗尽预检 - 现象:给完美像素加端点级并发闸后,预算已经耗尽的请求仍然能拿到许可,白占一个名额继续去打几轮全账号 SpacetimeDB 扫描,直到下载那步才失败。 @@ -76,6 +77,7 @@ - 处理:把递增封进一个 guard 结构体,递减放在它的 `Drop` 实现里;递增本身用 `fetch_update` 的 CAS,不能用「先读后加」——两个线程同时读到 `max - 1` 各自加一就会越界。拿到资源后立即 `drop(guard)` 让出队列名额,不要让它跟着许可一起活到请求结束。 - 验证:单测覆盖 CAS 边界(满了返回失败且计数不越界、上限为 0 时任何进入都失败),并由独立用例覆盖 guard 离开作用域后的计数归还。预算耗尽路径只断言 `504`,不得通过另一个测试也会修改的进程级 static before/after 来推断“未入队”,也不得用串行锁或 `--test-threads=1` 掩盖隔离问题。 - 关联:`server-rs/crates/api-server/src/editor_project.rs`(`try_enter_bounded_queue`、`EditorPixelArtSnapQueueGuard`)。 + ## Linux 生产脚本门禁不能假设本地也是 GNU userland - 现象:macOS 本地运行维护页、生产 API 部署和 Rust 产物门禁时,依次出现 `mv: illegal option -- T`、`mapfile: command not found`、`/usr/bin/cp` / `/usr/bin/chmod` 不存在,以及 `.rlib` 明明含有 `.o` 却报告“没有可扫描成员”;安全修复计划还会把 `/var/folders` 到 `/private/var/folders` 的系统别名误判为用户符号链接。 @@ -4021,6 +4023,7 @@ - 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。旧 `creation-entry:*` 目标、接口和页面保持退役。 - 验证:`adminRoutes` 必须包含 `gray-release`,admin-web TypeScript/ESLint/Vitest 不得排除灰度页;页面测试必须断言只请求 feature-gates,并继续覆盖现役固定 target、直接 Gate Key 保存与新 target 状态重置。 - 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx`、`apps/admin-web/src/app/adminRoutes.ts`、`server-rs/crates/api-server/src/modules/admin.rs`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 + ## 历史钱包消费不能从最近流水或通用订单快照推算 - 现象:后台用户详情要展示累计花费时,直接复用只返回最近 50 条的 `list_profile_wallet_ledger`,或在充值订单每行使用的通用钱包快照里扫描该用户全部流水。 @@ -4143,6 +4146,7 @@ - 处理:从当前 root source 的 seed lane 动态解析全部零依赖首波任务,只对这些 child 容忍 hydration `Pending`,后续 code prototype / preview 仍严格要求 Running/Completed。`streaming / ready` 仍要求当前 revision,`committed` 回复改为依据 finalization 的稳定身份查询,不随后续项目 revision 失效。 - 验证:覆盖 `design-director / art-director / code-director` 三个 Pending 首波 child 均可投影 Completed、`code-prototype` Pending 仍被拒绝;非流式专业 Agent 在 finalization 前无 stream,提交后形成 committed stream,再推进项目 revision 后仍可查询且正文不变。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs`。 + ## 异步生成结果未知时不能换幂等键重提(2026-07-31) - 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。 @@ -4187,6 +4191,13 @@ - 处理:调用方在未认证时不得启动受保护的钱包刷新;可取消的读取要为每轮分配 `AbortController`,新读取先失效并中止旧读取,组件卸载时同时推进 revision、abort 当前请求并清空句柄。所有 `then / catch / finally` 在更新状态前都要检查 signal 与 revision。 - 验证:定向测试覆盖卸载后请求 signal 已中止;同时复跑触发钱包刷新回调的画布生成集成测试和完整前端测试,不能以单文件偶然快速收束代替全量验证。 +## 账号级轮询和并发 bootstrap 必须中止整条旧生命周期(2026-08-06) + +- 现象:任务列表 `Promise.all` 一侧失败后,另一侧请求可能跨过重试和卸载继续悬挂;微信充值第一次确认返回 pending 后切换账号,旧订单的延迟重试可能使用新账号 Token 再次请求,401 路径还会影响新账号登录态。 +- 原因:只用 React state 或最终回调里的 owner 判断,无法阻止已安排的 timer、下一次 HTTP 请求和同轮未完成分支继续执行;每轮重试覆盖单个 controller ref,也会遗失更早的悬挂请求。 +- 处理:并发 bootstrap 每次 attempt 使用独立 `AbortController`,任一分支失败时先中止同轮 controller 再安排有界重试,卸载时中止当前 attempt。充值订单从创建成功起持有同一个 owner、账号 revision 和 `AbortController`;每次 delay、confirm 和 SSE watch 前后都校验生命周期,并把同一 signal 传到请求层;账号切换和卸载先 abort,再清理 ref、state 与旧支付回调 hash。 +- 验证:bootstrap 用例覆盖“一侧 reject、另一侧 pending、重试后卸载”,并断言每轮 signal 都已中止;充值用 fake timer 证明首次确认 pending 后切换账号会中止 signal,推进全部退避时间也不会产生第二个确认请求或清理新账号 Token。 + ## 下游 manifest 回调测试不能冒充实时数据源(2026-08-05) - 现象:工作台的资源、任务与版本重投影单测保持绿色,但后台 Agent 已更新 `.agent/manifest.json` 后,打开中的工作台仍长期显示旧快照,只有重开项目才更新。 @@ -4247,6 +4258,7 @@ - 现象:parent wake 的 200 次瞬态预算耗尽后 Runtime 仍长期显示 running,或 lane 忙、取消、child 前进、manifest 损坏时 reconciliation 被静默丢弃或覆盖新状态。 - 处理:预算耗尽错误必须向上传递;lane 忙先持久化 deferred signal,再在 lane + 项目锁内重检最新事实。结构损坏路径使用不依赖 manifest hydration 的专用 journal/state 写入,CAS 失败转为继续对账,绝不覆写并发取消或 DAG 进展。可解析的空对象/空 runId 仍是损坏身份,只有完整有效的新 Run 才能阻止旧 marker;event/audit 的同键记录必须完整比对并拒绝冲突或重复。旧 task 已终态、Runtime 非 waiting 或新 Run 接管时,deferred signal 必须写 resolved/superseded,不能留给后续 wake 永久重复 settle。 - 测试注意:autonomous child fixture 先 linked Pending、后正式 Running;终态 runId 必须拒绝复用。判断 Completed-only 诊断时按每个 seed task 的实际状态分析,不能因为 `code-prototype` Pending 就忽略已经 Completed 的 `art-asset-plan` 深验。 + ## macOS 安全路径测试必须使用规范化临时目录(2026-08-05) - 现象:调用仓库上下文、Runtime context bundle 或 pending recovery 的 Rust 测试在 macOS 报“Repository root and its ancestors must not be symbolic links”,Linux CI 却可能通过;本地 HTTP 恢复夹具在完整串行测试中还可能偶发 `WouldBlock`。 diff --git a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx index edc2708c6..ab759f3a3 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx @@ -178,6 +178,62 @@ describe('usePlatformProfileCenterController recharge fallback', () => { }); }); + test('aborts delayed confirmation retries when the account changes', async () => { + vi.useFakeTimers(); + try { + profileClientMocks.createPlatformProfileRechargeOrder.mockResolvedValue( + nativeRechargeResponse(), + ); + profileClientMocks.confirmWechatPlatformProfileRechargeOrder.mockResolvedValue( + { + order: nativeRechargeResponse().order, + center: nativeRechargeResponse().center, + }, + ); + usePlatformWalletStore.getState().setWalletOwner('user-a'); + const { result, rerender } = renderController(userA); + + await act(async () => { + result.current.buyRechargeProduct(rechargeProduct); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(result.current.nativeWechatPayment?.orderId).toBe('order-a'); + + await act(async () => { + result.current.confirmNativeWechatPayment(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect( + profileClientMocks.confirmWechatPlatformProfileRechargeOrder, + ).toHaveBeenCalledTimes(1); + const confirmationSignal = profileClientMocks + .confirmWechatPlatformProfileRechargeOrder.mock.calls[0]?.[1] + ?.signal as AbortSignal; + expect(confirmationSignal.aborted).toBe(false); + expect( + profileClientMocks.watchWechatPlatformProfileRechargeOrder, + ).toHaveBeenCalledWith('order-a', { signal: confirmationSignal }); + + act(() => { + rerender({ user: userB }); + usePlatformWalletStore.getState().setWalletOwner('user-b'); + }); + expect(confirmationSignal.aborted).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect( + profileClientMocks.confirmWechatPlatformProfileRechargeOrder, + ).toHaveBeenCalledTimes(1); + expect(apiClientMocks.clearStoredAccessToken).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + test('does not clear the new owner token from an old owner payment error', async () => { const pendingOrder = deferred(); paymentPlatformMocks.resolveProfileRechargeProductPaymentChannel.mockReturnValue( diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 517a3e0c0..5076c5712 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -84,6 +84,12 @@ type AccountLifecycle = Readonly<{ type WechatRechargeOrderLifecycle = Readonly<{ orderId: string; account: AccountLifecycle; + abortController: AbortController; +}>; + +type WechatRechargeConfirmationOptions = Readonly<{ + signal: AbortSignal; + isCurrent: () => boolean; }>; function isWechatJsapiMissingIdentityError(error: unknown) { @@ -182,9 +188,36 @@ function readWechatPayResultFromHash(): WechatPayResult | null { }; } -function waitWechatPayConfirmDelay(delayMs: number) { - return new Promise((resolve) => { - window.setTimeout(resolve, delayMs); +function createWechatRechargeAbortError(signal: AbortSignal) { + return signal.reason instanceof Error + ? signal.reason + : new DOMException('Wechat recharge confirmation aborted', 'AbortError'); +} + +function assertWechatRechargeConfirmationActive( + options: WechatRechargeConfirmationOptions, +) { + if (options.signal.aborted || !options.isCurrent()) { + throw createWechatRechargeAbortError(options.signal); + } +} + +function waitWechatPayConfirmDelay(delayMs: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(createWechatRechargeAbortError(signal)); + return; + } + const timerId = window.setTimeout(() => { + signal?.removeEventListener('abort', handleAbort); + resolve(); + }, delayMs); + const handleAbort = () => { + window.clearTimeout(timerId); + signal?.removeEventListener('abort', handleAbort); + reject(createWechatRechargeAbortError(signal!)); + }; + signal?.addEventListener('abort', handleAbort, { once: true }); }); } @@ -204,42 +237,71 @@ function isWechatRechargeOrderTerminalForConfirmation( async function confirmWechatRechargeOrderUntilSettled( orderId: string, + options: WechatRechargeConfirmationOptions, ): Promise { - let latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId); + assertWechatRechargeConfirmationActive(options); + let latestResponse = await confirmWechatPlatformProfileRechargeOrder( + orderId, + { signal: options.signal }, + ); + assertWechatRechargeConfirmationActive(options); if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } for (const delayMs of WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS) { - await waitWechatPayConfirmDelay(delayMs); + assertWechatRechargeConfirmationActive(options); + await waitWechatPayConfirmDelay(delayMs, options.signal); + assertWechatRechargeConfirmationActive(options); - latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId); + latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId, { + signal: options.signal, + }); + assertWechatRechargeConfirmationActive(options); if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } } try { - const streamedResponse = - await watchWechatPlatformProfileRechargeOrder(orderId); + assertWechatRechargeConfirmationActive(options); + const streamedResponse = await watchWechatPlatformProfileRechargeOrder( + orderId, + { + signal: options.signal, + }, + ); + assertWechatRechargeConfirmationActive(options); return streamedResponse; } catch { + assertWechatRechargeConfirmationActive(options); return latestResponse; } } async function confirmWechatRechargeOrderQuickly( orderId: string, + options: WechatRechargeConfirmationOptions, ): Promise { - let latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId); + assertWechatRechargeConfirmationActive(options); + let latestResponse = await confirmWechatPlatformProfileRechargeOrder( + orderId, + { signal: options.signal }, + ); + assertWechatRechargeConfirmationActive(options); if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } for (const delayMs of WECHAT_NATIVE_CONFIRM_RETRY_DELAYS_MS) { - await waitWechatPayConfirmDelay(delayMs); + assertWechatRechargeConfirmationActive(options); + await waitWechatPayConfirmDelay(delayMs, options.signal); + assertWechatRechargeConfirmationActive(options); - latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId); + latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId, { + signal: options.signal, + }); + assertWechatRechargeConfirmationActive(options); if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } @@ -404,6 +466,38 @@ export function usePlatformProfileCenterController({ }, [], ); + const abortRechargeOrders = useCallback( + (...orders: Array) => { + const controllers = new Set( + orders.flatMap((order) => (order ? [order.abortController] : [])), + ); + controllers.forEach((controller) => controller.abort()); + }, + [], + ); + const createRechargeOrderLifecycle = useCallback( + ( + orderId: string, + account: AccountLifecycle, + ): WechatRechargeOrderLifecycle => ({ + orderId, + account, + abortController: new AbortController(), + }), + [], + ); + const createRechargeConfirmationOptions = useCallback( + ( + order: WechatRechargeOrderLifecycle, + ): WechatRechargeConfirmationOptions => ({ + signal: order.abortController.signal, + isCurrent: () => + isRechargeOrderCurrent(order) && + (isSameRechargeOrder(pendingWechatRechargeOrderRef.current, order) || + isSameRechargeOrder(confirmingWechatRechargeOrderRef.current, order)), + }), + [isRechargeOrderCurrent, isSameRechargeOrder], + ); const walletOwnerUserId = usePlatformWalletStore( (state) => state.ownerUserId, ); @@ -429,6 +523,15 @@ export function usePlatformProfileCenterController({ rechargeCenterReadRevisionRef.current += 1; accountLifecycleRevisionRef.current += 1; walletLedgerReadRevisionRef.current += 1; + const pendingOrder = pendingWechatRechargeOrderRef.current; + const confirmingOrder = confirmingWechatRechargeOrderRef.current; + abortRechargeOrders(pendingOrder, confirmingOrder); + if ( + (pendingOrder && pendingOrder.account.ownerUserId !== currentUserId) || + (confirmingOrder && confirmingOrder.account.ownerUserId !== currentUserId) + ) { + clearWechatPayResultHash(); + } pendingWechatRechargeOrderRef.current = null; confirmingWechatRechargeOrderRef.current = null; setRechargeCenter(null); @@ -448,7 +551,7 @@ export function usePlatformProfileCenterController({ setRewardCodeError(null); setRewardCodeSuccess(null); setIsSubmittingReferralRedeem(false); - }, [currentUserId]); + }, [abortRechargeOrders, currentUserId]); const rechargeCenterReadAbortControllerRef = useRef( null, ); @@ -457,10 +560,16 @@ export function usePlatformProfileCenterController({ return () => { rechargeCenterReadRevisionRef.current += 1; accountLifecycleRevisionRef.current += 1; + abortRechargeOrders( + pendingWechatRechargeOrderRef.current, + confirmingWechatRechargeOrderRef.current, + ); + pendingWechatRechargeOrderRef.current = null; + confirmingWechatRechargeOrderRef.current = null; rechargeCenterReadAbortControllerRef.current?.abort(); rechargeCenterReadAbortControllerRef.current = null; }; - }, []); + }, [abortRechargeOrders]); // 中文注释:支持带邀请码 query 的直达场景,登录成功后自动打开兑换面板并复用同一套输入状态。 useEffect(() => { @@ -620,12 +729,16 @@ export function usePlatformProfileCenterController({ } loadRechargeCenter(); setSubmittingRechargeProductId(null); + abortRechargeOrders( + pendingWechatRechargeOrderRef.current, + confirmingWechatRechargeOrderRef.current, + ); pendingWechatRechargeOrderRef.current = null; confirmingWechatRechargeOrderRef.current = null; setWechatRechargeOrderConfirmationState(null); setNativeWechatPayment(null); }, - [isAccountLifecycleCurrent, loadRechargeCenter], + [abortRechargeOrders, isAccountLifecycleCurrent, loadRechargeCenter], ); const handleWechatPayResult = useCallback(() => { @@ -657,7 +770,8 @@ export function usePlatformProfileCenterController({ clearWechatPayResultHash(); return true; } - const order = pendingOrder ?? { orderId, account }; + const order = + pendingOrder ?? createRechargeOrderLifecycle(orderId, account); if ( isSameRechargeOrder(confirmingWechatRechargeOrderRef.current, order) ) { @@ -668,7 +782,10 @@ export function usePlatformProfileCenterController({ setWechatRechargeOrderConfirmationState({ orderId }); setSubmittingRechargeProductId(null); setRechargePaymentResult(null); - void confirmWechatRechargeOrderUntilSettled(orderId) + void confirmWechatRechargeOrderUntilSettled( + orderId, + createRechargeConfirmationOptions(order), + ) .then((response) => { if ( !isRechargeOrderCurrent(order) || @@ -690,6 +807,7 @@ export function usePlatformProfileCenterController({ pendingWechatRechargeOrderRef.current = null; } confirmingWechatRechargeOrderRef.current = null; + order.abortController.abort(); setWechatRechargeOrderConfirmationState(null); setRechargePaymentResult(result); if (isPaid) { @@ -742,6 +860,8 @@ export function usePlatformProfileCenterController({ }, [ applyRechargeCenter, captureAccountLifecycle, + createRechargeConfirmationOptions, + createRechargeOrderLifecycle, isAccountLifecycleCurrent, isRechargeOrderCurrent, isSameRechargeOrder, @@ -770,7 +890,10 @@ export function usePlatformProfileCenterController({ confirmingWechatRechargeOrderRef.current = order; setWechatRechargeOrderConfirmationState({ orderId: order.orderId }); setRechargePaymentResult(null); - void confirmWechatRechargeOrderUntilSettled(order.orderId) + void confirmWechatRechargeOrderUntilSettled( + order.orderId, + createRechargeConfirmationOptions(order), + ) .then((response) => { if ( !isRechargeOrderCurrent(order) || @@ -785,6 +908,7 @@ export function usePlatformProfileCenterController({ } pendingWechatRechargeOrderRef.current = null; confirmingWechatRechargeOrderRef.current = null; + order.abortController.abort(); setWechatRechargeOrderConfirmationState(null); setSubmittingRechargeProductId(null); setRechargePaymentResult(result); @@ -810,6 +934,7 @@ export function usePlatformProfileCenterController({ return true; }, [ applyRechargeCenter, + createRechargeConfirmationOptions, isRechargeOrderCurrent, isSameRechargeOrder, nativeWechatPayment, @@ -852,6 +977,7 @@ export function usePlatformProfileCenterController({ pendingOrder?.orderId === current.orderId && isRechargeOrderCurrent(pendingOrder) ) { + pendingOrder.abortController.abort(); pendingWechatRechargeOrderRef.current = null; } return null; @@ -885,12 +1011,17 @@ export function usePlatformProfileCenterController({ if (!isAccountLifecycleCurrent(account)) { return; } - const order: WechatRechargeOrderLifecycle = { - orderId: response.order.orderId, + const order = createRechargeOrderLifecycle( + response.order.orderId, account, - }; + ); + abortRechargeOrders( + pendingWechatRechargeOrderRef.current, + confirmingWechatRechargeOrderRef.current, + ); createdOrder = order; pendingWechatRechargeOrderRef.current = order; + confirmingWechatRechargeOrderRef.current = null; if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) { if ( @@ -944,7 +1075,10 @@ export function usePlatformProfileCenterController({ title: '支付处理中', message: '正在查询微信支付到账状态。', }); - void confirmWechatRechargeOrderUntilSettled(response.order.orderId) + void confirmWechatRechargeOrderUntilSettled( + response.order.orderId, + createRechargeConfirmationOptions(order), + ) .then((confirmResponse) => { if (!isRechargeOrderCurrent(order)) { return; @@ -968,6 +1102,7 @@ export function usePlatformProfileCenterController({ ) { pendingWechatRechargeOrderRef.current = null; } + order.abortController.abort(); } if (isPaid) { void onRechargeSuccess?.(); @@ -1056,6 +1191,7 @@ export function usePlatformProfileCenterController({ createdOrder, ) ) { + createdOrder.abortController.abort(); pendingWechatRechargeOrderRef.current = null; } setNativeWechatPayment(null); @@ -1114,8 +1250,11 @@ export function usePlatformProfileCenterController({ }, [ applyRechargeCenter, + abortRechargeOrders, captureAccountLifecycle, captureWalletBalanceSnapshot, + createRechargeConfirmationOptions, + createRechargeOrderLifecycle, isAccountLifecycleCurrent, isRechargeOrderCurrent, isSameRechargeOrder, @@ -1141,7 +1280,10 @@ export function usePlatformProfileCenterController({ ? { ...current, isConfirming: true, confirmMessage: undefined } : current, ); - void confirmWechatRechargeOrderQuickly(nativeWechatPayment.orderId) + void confirmWechatRechargeOrderQuickly( + nativeWechatPayment.orderId, + createRechargeConfirmationOptions(order), + ) .then((response) => { if (!isRechargeOrderCurrent(order)) { return; @@ -1158,6 +1300,7 @@ export function usePlatformProfileCenterController({ ) { pendingWechatRechargeOrderRef.current = null; } + order.abortController.abort(); setRechargePaymentResult(result); if (isPaid) { void onRechargeSuccess?.(); @@ -1195,6 +1338,7 @@ export function usePlatformProfileCenterController({ }); }, [ applyRechargeCenter, + createRechargeConfirmationOptions, isRechargeOrderCurrent, isSameRechargeOrder, nativeWechatPayment, @@ -1215,20 +1359,22 @@ export function usePlatformProfileCenterController({ } let cancelled = false; - const abortController = new AbortController(); + const confirmationOptions = createRechargeConfirmationOptions(order); const watchUntilSettled = async () => { while ( !cancelled && Date.now() < expiresAtMs && - isRechargeOrderCurrent(order) + confirmationOptions.isCurrent() ) { try { + assertWechatRechargeConfirmationActive(confirmationOptions); const response = await watchWechatPlatformProfileRechargeOrder( orderId, { - signal: abortController.signal, + signal: confirmationOptions.signal, }, ); + assertWechatRechargeConfirmationActive(confirmationOptions); if ( cancelled || !response || @@ -1242,7 +1388,11 @@ export function usePlatformProfileCenterController({ return; } if (result.kind === 'pending') { - await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS); + await waitWechatPayConfirmDelay( + WECHAT_NATIVE_WATCH_RETRY_DELAY_MS, + confirmationOptions.signal, + ); + assertWechatRechargeConfirmationActive(confirmationOptions); continue; } @@ -1252,6 +1402,7 @@ export function usePlatformProfileCenterController({ ) { confirmingWechatRechargeOrderRef.current = null; } + order.abortController.abort(); setNativeWechatPayment((current) => current?.orderId === orderId ? null : current, ); @@ -1264,26 +1415,34 @@ export function usePlatformProfileCenterController({ } catch { if ( cancelled || - abortController.signal.aborted || - !isRechargeOrderCurrent(order) + confirmationOptions.signal.aborted || + !confirmationOptions.isCurrent() ) { return; } } - await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS); + try { + await waitWechatPayConfirmDelay( + WECHAT_NATIVE_WATCH_RETRY_DELAY_MS, + confirmationOptions.signal, + ); + assertWechatRechargeConfirmationActive(confirmationOptions); + } catch { + return; + } } }; void watchUntilSettled(); return () => { cancelled = true; - abortController.abort(); }; }, [ nativeWechatPayment?.expiresAt, nativeWechatPayment?.orderId, applyRechargeCenter, + createRechargeConfirmationOptions, isRechargeOrderCurrent, isSameRechargeOrder, onRechargeSuccess, -- 2.52.0 From 1fbfb9982d9388010335a124f4333c60f0cdc16b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 15:14:13 +0800 Subject: [PATCH 22/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=88=86=E6=94=AF?= =?UTF-8?q?=E5=89=A9=E4=BD=99=E6=A0=BC=E5=BC=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 Prettier 统一钱包、平台入口和画布相关分支文件格式。 补齐共享决策文档缺失的 Markdown 段落间距。 --- .../shared-memory/decision-log.md | 8 +++++- .../image-editor/ImageCanvasStageView.tsx | 3 +-- .../PlatformEntryActiveFlowShell.test.tsx | 26 ++++++++++--------- .../PlatformEntryActiveFlowShell.tsx | 4 +-- src/stores/usePlatformWalletStore.ts | 3 +-- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b65b5330c..3eed81688 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -116,6 +116,7 @@ - 影响范围:AI 游戏创作 `runtime_driver/task_start.rs`、`task_queue.rs`、自主构建 continuation 合同、Supervisor 进度卡与相应 Rust/AppSurface 回归;不改变 manifest DAG、Agent catalog、Provider 路由或项目产物合同。 - 验证方式:不预占 child locks,真实一次调度三项首波任务,并在有界时间内证明每个逻辑 Run 至少写入 running/`turn.started`;重复调度不得新增逻辑 Run。前端固定时钟覆盖正常运行、子 Agent 新活动、疑似停滞、各类合法等待与 terminal 冻结。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`。 + ## 2026-07-31 图集切片按需编码并批量确认持久化 - 背景:`2026-07-29 图集切片必须受前置容量和有界 CPU 保护` 收口了连通域数量与 CPU 并发,但切片仍在一次循环里全部裁剪并编码,最多 64 份 PNG 字节连同整张 RGBA 同时驻留内存;持久化又按切片逐个调用 procedure,N 片至少 2N 次写入外加一次 cohort 完成,任一片失败都会留下已确认的部分记录。手动拆分入口另有一处重复鉴权:`get_editor_project` 已经取回并定位了来源资源,随后仍走 `parse_editor_reference_image` 按注册 ID 再解析一次,触发全账号项目与素材库扫描。 @@ -128,6 +129,7 @@ - 验证方式:`platform-image` 覆盖 prepare 不编码且 `Send + Sync`、并发编码多个 index 结果不变、累计裁剪像素在编码前拒绝;`api-server` 覆盖切片记录 ID 稳定且按 owner / index 分区、自动路径保留处理超时告警码、上传超时释放内存许可;`spacetime-module` 覆盖批次校验的完整 cohort、重复 objectKey、来源资源同 owner 同 project、部分 cohort 拒绝与重放只在内容一致时复用。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、本文件 `2026-07-29 图集切片必须受前置容量和有界 CPU 保护`。 - 补记说明:本条为事后补写,记录提交 `cf1a02312` 已落地的行为,不改变其任何决策。 + ## 2026-07-31 AI 游戏创作资源依赖图采用 Rust 只读拓扑与前端派生 SVG > 状态:其中资源卡 Pointer Move 拖动预览与局部更新验收已由 2026-08-03 mentor 最新决定暂缓;只读拓扑、SVG 派生展示、搜索与选择高亮合同继续生效。 @@ -1217,7 +1219,7 @@ ## 2026-06-18 图片画布 Seedance 2.0 参考媒体提交边界 - 背景:`/editor/canvas` 生成视频需要严格对齐火山 Seedance 2.0 多模态参考输入;参考视频若继续走 Base64 / `data:video` 会超过请求体并被上游拒绝,参考音频单独输入和非 Seedance 模型携带参考字段也会违反文档契约。 -- 决策:仅 `seedance2.0-fast` / `seedance2.0` 可提交参考图片、参考视频、参考音频;图片 0~9、视频 0~3、音频 0~3,音频必须搭配图片或视频。参考视频只能提交公网 URL、`asset://` 或画板资源 `objectKey`,禁止 `data:video/*`;视频 / 音频上传先走 OSS 直传和 asset*object confirm,前端保存 signed URL 预览但提交优先 `objectKey`,后端统一重新签名给 Ark。Ark body 按 `image_url` / `video_url` / `audio_url` + `reference*\*`role 构造,并显式发送`generate_audio:false`。 +- 决策:仅 `seedance2.0-fast` / `seedance2.0` 可提交参考图片、参考视频、参考音频;图片 0~~9、视频 0~~3、音频 0~3,音频必须搭配图片或视频。参考视频只能提交公网 URL、`asset://` 或画板资源 `objectKey`,禁止 `data:video/*`;视频 / 音频上传先走 OSS 直传和 asset*object confirm,前端保存 signed URL 预览但提交优先 `objectKey`,后端统一重新签名给 Ark。Ark body 按 `image_url` / `video_url` / `audio_url` + `reference*\*`role 构造,并显式发送`generate_audio:false`。 - 影响范围:图片画布生成视频面板、参考媒体上传工作流、`editorReferenceUploadClient`、`ImageCanvasGenerationSubmissionModel`、`shared-contracts`、`api-server` 编辑器视频 BFF、Lovart 生成类面板文档。 - 验证方式:运行 `npx vitest run src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/services/image-editor/editorReferenceUploadClient.test.ts --reporter verbose`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts editor_video_request_supports_seedance_multimodal_references --manifest-path server-rs/Cargo.toml`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、火山 Seedance 2.0 任务创建文档。 @@ -6021,6 +6023,7 @@ - 占位删除与重试:completion 必须读取当前权威 dialog;若删除已先持久化,只跳过画布 layer / dialog 写回,不得使用请求中的旧 placeholder 复活图层,已经成功持久化的 project resource / 账号素材允许保留。若回包时本地占位已删除,前端不得应用完成快照或写历史;现有布局 CAS 没有 deletion tombstone,因此 completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口,绝对“删除意图胜出”留待 targeted delete / tombstone 方案。该路由是 unsafe POST,客户端不得配置 `EDITOR_REQUEST_RETRY_OPTIONS`;请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放,Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照,由用户显式决定是否再次执行。 - 历史边界:成功加入画布时写一条 `perfect-pixel` 历史,中文标签为“完美像素”,并纳入新增结果保护;撤销不得让派生 PNG 消失。像素处理失败或 completion 因占位删除未落画布时不写该历史。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`。 + ## 2026-07-31 game-chat 每条输出入聊天、试玩后收束与平台图集引用 - 背景:game-chat 的 ready response 之前只作为 transient stream 展示,专业 Agent 的 `final-reply` 只进入各自私有 conversation,刷新或事件 / 轮询重放时项目聊天可能丢失这些输出;自主构建完成后仍可能继续进入发布任务;配置 External Editor API 时,原型 HTML 也可能不实际使用平台生成的 Canvas 美术资源。 @@ -6402,6 +6405,7 @@ - 权威性与剩余风险:preflight 不创建锁、reservation 或新表记录;最终 `persist_editor_pixel_art_result_and_return` 仍在同一事务内重复目录、布局、幂等 identity 和 revision 校验。preflight 通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;彻底消除该 TOCTOU 需要 durable reservation / journal 或事务协调,不在本 PR 的最小修复边界内。 - 契约影响:只新增 SpacetimeDB procedure ABI 与生成 bindings;没有表字段、index、migration、HTTP DTO、路由、状态码、OpenAPI 或 shared-contracts 变化。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-08-03 主站与 AI Game Creator 复用单一泥点钱包 Store - 背景:主站顶部优先读取 dashboard 总额,图片画板独立轮询 dashboard,充值 controller 和 AI Game Creator 又分别保存充值中心明细;不同请求返回时序不一致会让总额与分桶同时显示不同快照,快速生成或切换账号时旧响应还可能回滚余额。 @@ -6471,6 +6475,7 @@ - classic script 分析单元把 inline 与无 `defer / async` 的本地 external 正文按 `game/index.html` 标签顺序交错组成 parser-blocking 段,再把 classic external `defer` 按文档顺序放到解析完成后的 deferred 段;不得把 defer-before-inline 误投影为外链先执行。classic external `async` 的下载完成顺序不可静态证明,当前静态门直接失败关闭。带 `src` 标签的 inline body 继续忽略;外部文件仍执行可信普通文件、`game/` 边界、文件数与累计体积门禁,重复标签按浏览器出现次数保留求值位置。 - Canvas 尺寸、可见性、元素绑定和 stylesheet 选择器扫描只消费浏览器可渲染标记;`template / textarea / noscript / title / style / xmp / iframe / noembed / plaintext` 内的 Canvas、标签和样式诱饵全部跳过。活动顶层 stylesheet 与可见标记分开提取,既允许真实 CSS 参与隐藏/尺寸判断,也不把 CSS raw-text 中的伪标签当作 DOM。 - ESM 组合单元按 dependency 初始化先于 importer 顶层求值排列。import reference 的 span replacement 仍基于原 importer 完成,随后把已闭包的 dependency projection 放在 importer 前并对最终单元重跑 parser、semantic、单元 `2 MiB` 与累计投影 `32 MiB` 门禁;循环模块继续按 `(origin module, original root binding)` canonical identity 去重并要求有界固定点收敛。 + ## 2026-08-04 JavaScript 延迟状态与复合调用边 - 受控异步 callback 的 alias 读取按完整 enclosing invocation 链延迟到各层函数同步收尾,最外层再延迟到当前 job 末尾;callback 写入仍不在注册点同步提交。conditional / assignment expression callee 分别在 test / RHS 求值后建立调用边,`new` 同时执行普通 function constructor 及 alias。 @@ -6656,6 +6661,7 @@ - 遗留(建议单开,不在本次范围):`loadProjectCoverImage` 里无 timeout / 无 AbortSignal 的 `new Image()` 本身仍是隐患,自动保存路径一样会踩。本次只是把它移出生成链的关键路径,没有消除它。 - 影响范围:`useImageCanvasProjectPersistence.ts` 的 `flushProjectPersistence`。不改服务端、不改契约。 - 验证方式:既有用例「flush 等待封面缓存」翻转为「flush 不等封面、但封面链照常跑完并完成上传与资源登记」;新增「封面永不 settle 时 flush 仍返回」——用永不 resolve 的 blob 模拟 `new Image()` 不 settle,并断言 `createProjectCoverSnapshotBlob` 确实被调用过以防用例空过。已实证:回退修复后新用例报 `expected 'false' to be 'true'`。运行 `npx vitest run src/components/image-editor src/components/platform-entry src/services`(101 文件 / 1241 项)、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 + ## 2026-08-05 编辑器生成请求与参考图权威契约 - 主站编辑器生成 POST 不做浏览器自动重试,避免 inline 模式在响应丢失后重复调用 provider;api-server 仍使用独立 namespace + owner + job kind + request id 生成队列 `dedupe_key`,让显式复用同一请求标识的队列重放原子返回已存在任务,并对同键不同 payload 返回 `409`。外部 v1 的 `Idempotency-Key` 保持独立 namespace。 diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 9d8d62480..f169e8195 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -423,8 +423,7 @@ export function ImageCanvasStageView({ selectedLayer && perfectPixelLayerIds.has(selectedLayer.id), )} isPerfectPixelPendingConfirmation={Boolean( - selectedLayer && - pendingPerfectPixelLayerIds.has(selectedLayer.id), + selectedLayer && pendingPerfectPixelLayerIds.has(selectedLayer.id), )} onOpenQuickEditPanel={onOpenQuickEditPanel} onOpenRedrawPanel={onOpenRedrawPanel} diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index e250797d2..c57880028 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -1,6 +1,13 @@ /* @vitest-environment jsdom */ -import { act, fireEvent, render, screen, waitFor, 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'; @@ -95,8 +102,9 @@ vi.mock('../image-editor/ImageCanvasEditorView', () => ({ ImageCanvasEditorView: () =>
, })); -vi.mock('../../services/platform-entry/platformProfileClient', () => - profileClientMock, +vi.mock( + '../../services/platform-entry/platformProfileClient', + () => profileClientMock, ); vi.mock('./usePlatformProfileCenterController', () => ({ @@ -455,13 +463,9 @@ describe('PlatformEntryActiveFlowShell', () => { expect( await screen.findByRole('main', { name: '桌面端创作提示' }), ).toBeTruthy(); - expect( - screen.queryByRole('main', { name: '陶泥儿创作主页' }), - ).toBeNull(); + expect(screen.queryByRole('main', { name: '陶泥儿创作主页' })).toBeNull(); expect(screen.queryByRole('main', { name: '项目' })).toBeNull(); - expect( - screen.queryByRole('main', { name: '图片画布编辑器' }), - ).toBeNull(); + expect(screen.queryByRole('main', { name: '图片画布编辑器' })).toBeNull(); }, ); @@ -477,9 +481,7 @@ describe('PlatformEntryActiveFlowShell', () => { expect( await screen.findByRole('main', { name: '桌面端创作提示' }), ).toBeTruthy(); - expect( - screen.queryByRole('main', { name: '图片画布编辑器' }), - ).toBeNull(); + expect(screen.queryByRole('main', { name: '图片画布编辑器' })).toBeNull(); }); it('switches between creation and projects and passes search to the active page', async () => { diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index e40daf291..bd3a83361 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -542,9 +542,7 @@ export function PlatformEntryFlowShellImpl({ authUi?.openLoginModal()} diff --git a/src/stores/usePlatformWalletStore.ts b/src/stores/usePlatformWalletStore.ts index ff58a7b16..d4b40e868 100644 --- a/src/stores/usePlatformWalletStore.ts +++ b/src/stores/usePlatformWalletStore.ts @@ -4,8 +4,7 @@ import { createProfileWalletStore } from '@/packages/shared/src'; import { getPlatformProfileRechargeCenter } from '@/src/services/platform-entry/platformProfileClient.ts'; export const usePlatformWalletStore = createProfileWalletStore({ - getRechargeCenter: (signal) => - getPlatformProfileRechargeCenter({ signal }), + getRechargeCenter: (signal) => getPlatformProfileRechargeCenter({ signal }), }); export function usePlatformWalletLifecycle( -- 2.52.0 From 532df4d23106443e9ed9124df5737cc53e6cb6e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 15:21:15 +0800 Subject: [PATCH 23/36] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=85=85=E5=80=BC?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=BB=B6=E8=BF=9F=E4=BF=A1=E5=8F=B7=E7=BA=A6?= =?UTF-8?q?=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 要求所有微信充值确认延迟显式传入 AbortSignal。 移除可选 signal 分支,防止后续调用绕过订单生命周期取消。 --- .../usePlatformProfileCenterController.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 5076c5712..db0d61d55 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -202,22 +202,22 @@ function assertWechatRechargeConfirmationActive( } } -function waitWechatPayConfirmDelay(delayMs: number, signal?: AbortSignal) { +function waitWechatPayConfirmDelay(delayMs: number, signal: AbortSignal) { return new Promise((resolve, reject) => { - if (signal?.aborted) { + if (signal.aborted) { reject(createWechatRechargeAbortError(signal)); return; } const timerId = window.setTimeout(() => { - signal?.removeEventListener('abort', handleAbort); + signal.removeEventListener('abort', handleAbort); resolve(); }, delayMs); const handleAbort = () => { window.clearTimeout(timerId); - signal?.removeEventListener('abort', handleAbort); - reject(createWechatRechargeAbortError(signal!)); + signal.removeEventListener('abort', handleAbort); + reject(createWechatRechargeAbortError(signal)); }; - signal?.addEventListener('abort', handleAbort, { once: true }); + signal.addEventListener('abort', handleAbort, { once: true }); }); } -- 2.52.0 From 4200c0073a8cbd4b594632dc8f4d0c543584296c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 16:56:57 +0800 Subject: [PATCH 24/36] =?UTF-8?q?=E6=81=A2=E5=A4=8D=20Seedance=20=E5=8F=82?= =?UTF-8?q?=E8=80=83=E6=95=B0=E9=87=8F=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将图片、视频和音频参考数量范围恢复为原权威值。 使用行内代码保护波浪号范围,避免 Prettier 改写为 Markdown 删除线。 --- docs/project-memory/shared-memory/decision-log.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e543fbabb..f8f2adead 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1230,7 +1230,7 @@ ## 2026-06-18 图片画布 Seedance 2.0 参考媒体提交边界 - 背景:`/editor/canvas` 生成视频需要严格对齐火山 Seedance 2.0 多模态参考输入;参考视频若继续走 Base64 / `data:video` 会超过请求体并被上游拒绝,参考音频单独输入和非 Seedance 模型携带参考字段也会违反文档契约。 -- 决策:仅 `seedance2.0-fast` / `seedance2.0` 可提交参考图片、参考视频、参考音频;图片 0~~9、视频 0~~3、音频 0~3,音频必须搭配图片或视频。参考视频只能提交公网 URL、`asset://` 或画板资源 `objectKey`,禁止 `data:video/*`;视频 / 音频上传先走 OSS 直传和 asset*object confirm,前端保存 signed URL 预览但提交优先 `objectKey`,后端统一重新签名给 Ark。Ark body 按 `image_url` / `video_url` / `audio_url` + `reference*\*`role 构造,并显式发送`generate_audio:false`。 +- 决策:仅 `seedance2.0-fast` / `seedance2.0` 可提交参考图片、参考视频、参考音频;图片 `0~9`、视频 `0~3`、音频 `0~3`,音频必须搭配图片或视频。参考视频只能提交公网 URL、`asset://` 或画板资源 `objectKey`,禁止 `data:video/*`;视频 / 音频上传先走 OSS 直传和 asset*object confirm,前端保存 signed URL 预览但提交优先 `objectKey`,后端统一重新签名给 Ark。Ark body 按 `image_url` / `video_url` / `audio_url` + `reference*\*`role 构造,并显式发送`generate_audio:false`。 - 影响范围:图片画布生成视频面板、参考媒体上传工作流、`editorReferenceUploadClient`、`ImageCanvasGenerationSubmissionModel`、`shared-contracts`、`api-server` 编辑器视频 BFF、Lovart 生成类面板文档。 - 验证方式:运行 `npx vitest run src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/services/image-editor/editorReferenceUploadClient.test.ts --reporter verbose`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts editor_video_request_supports_seedance_multimodal_references --manifest-path server-rs/Cargo.toml`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、火山 Seedance 2.0 任务创建文档。 -- 2.52.0 From d063580c0ac2d46655200e03e05be288f2a71d54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 17:17:22 +0800 Subject: [PATCH 25/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 package-lock 锁定的 Prettier 3.3.3 格式化图片画布与充值 controller。 修正布尔表达式缩进、复合否定条件和充值结果联合类型换行。 --- src/components/image-editor/ImageCanvasEditorView.tsx | 6 ++++-- src/components/image-editor/ImageCanvasStageView.tsx | 2 +- .../platform-entry/usePlatformProfileCenterController.ts | 6 +++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 1dbc32948..5e25d60d2 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1031,8 +1031,10 @@ export function ImageCanvasEditorView({ layer.assetKind === 'character-animation' && (persistedAssetKind !== 'character-animation' || (asset.imageSequenceFrames?.length ?? 0) < 2 || - !(asset.imageSequenceDurationMs && - asset.imageSequenceDurationMs > 0)) + !( + asset.imageSequenceDurationMs && + asset.imageSequenceDurationMs > 0 + )) ) { throw new Error('服务器未返回完整的角色动作正式字段'); } diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index f169e8195..544845781 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -414,7 +414,7 @@ export function ImageCanvasStageView({ selectedToolbarStyle={selectedToolbarStyle} isSplittingIconSpritesheet={Boolean( selectedLayer && - splittingIconSpritesheetLayerIds.has(selectedLayer.id), + splittingIconSpritesheetLayerIds.has(selectedLayer.id), )} isPersistingAssetKind={Boolean( selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id), diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index db0d61d55..1da0f2634 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -61,7 +61,11 @@ type WechatPayResult = { }; type RechargePaymentResultKind = - 'success' | 'pending' | 'cancel' | 'failed' | 'expired'; + | 'success' + | 'pending' + | 'cancel' + | 'failed' + | 'expired'; export type RechargePaymentResult = { kind: RechargePaymentResultKind; -- 2.52.0 From ab66a50cfa255f4a04ccaabdc1e1e1bd56b7f355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 19:15:12 +0800 Subject: [PATCH 26/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=90=8E=E5=BC=82=E6=AD=A5=E7=95=8C=E9=9D=A2=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 活动精选图片换签断言使用局部有界等待 等待 manifest 失效监听注册后再验证实时重投影 补充异步测试超时与监听时序的项目记忆 --- .../tests/appSurface/home.suite.ts | 12 +++++++++++- docs/project-memory/shared-memory/pitfalls.md | 4 ++-- .../creation-home/CreationLandingView.test.tsx | 8 +++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 1ff1f578f..e718dc847 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -303,13 +303,23 @@ export function registerClientHomeTests() { ([command]) => command === 'inspect_local_project_directory', ).length; + await waitFor(() => { + expect(runtimeHarness.listen).toHaveBeenCalledWith( + 'game-creator-manifest-invalidated', + expect.any(Function), + ); + }); manifestChanged = true; act(() => { runtimeHarness.emitManifestInvalidated('art-asset-plan'); }); expect( - await screen.findByRole('button', { name: /runtime-live-hero\.png/ }), + await screen.findByRole( + 'button', + { name: /runtime-live-hero\.png/ }, + { timeout: 5_000 }, + ), ).not.toBeNull(); expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f16e77123..d3109b4f5 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -276,7 +276,7 @@ - 现象:测试点击“添加素材”后,图层状态已经写入,但立即用 `getByAltText('画布图片:...')` 偶发或稳定找不到图片;前一张图可能通过,紧接着添加的第二张失败。 - 原因:带 `objectKey` 的画布图片通过 `useResolvedAssetReadUrl` 异步获取签名 URL,`resolvedUrl` 就绪前不会渲染带 `alt` 的 ``。`user.click` 只等待点击交互完成,不等待 effect 内的换签 Promise;前一张图在后续操作期间出现只是调度时机,不是同步契约。 -- 处理:每次点击添加后分别用 `await screen.findByAltText(...)` 等待对应图片可见,再执行依赖该图层的下一步操作;不要用固定 sleep,也不要只等待最后一张图而让前面的断言依赖偶然调度。 +- 处理:每次点击添加后分别用 `await screen.findByAltText(...)` 等待对应图片可见,再执行依赖该图层的下一步操作;不要用固定 sleep,也不要只等待最后一张图而让前面的断言依赖偶然调度。完整前端回归并行负载较高时,可只对明确跨越换签 Promise 的目标查询设置局部、有界的 `5_000ms` 超时,不要放宽 Testing Library 全局超时。 - 验证:先精确运行目标用例并连续重复,再运行所在测试文件和完整前端测试;删除场景仍要保留 A/B 都消失、两个删除调用和撤销不恢复已删除素材的断言。 - 关联:`src/hooks/useResolvedAssetReadUrl.ts`、`src/components/image-editor/ImageCanvasWorldView.tsx`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。 @@ -4240,7 +4240,7 @@ - 现象:工作台的资源、任务与版本重投影单测保持绿色,但后台 Agent 已更新 `.agent/manifest.json` 后,打开中的工作台仍长期显示旧快照,只有重开项目才更新。 - 原因:测试 Supervisor 直接调用 `onManifestChange`,只证明 `App manifest -> WorkspaceLauncher -> ProjectDevelopmentView` 的下游桥接;真实 Runtime event 没有失效字段,监听器也没有重读 manifest。External Runner 又与 GUI 分属不同进程,Runner 内无法使用 GUI `AppHandle`,只补普通 Tauri event 仍不能形成生产链路。 - 处理:后台 manifest mutation 收敛到共用 Runtime emitter;GUI 内进程用带 `manifestInvalidated` 的 Runtime update,External Runner 通过 GUI owner attach 登记的受令牌保护 loopback sink 转发专用失效事件。App 对当前项目做 single-flight manifest 重读,并以 mounted、项目路径和 scope version 丢弃迟到结果;WorkspaceLauncher 继续只消费完整 manifest 快照,不新增平行状态或轮询。 -- 验证:集成测试必须渲染真实 `App + WorkspaceLauncher`、捕获真实 Tauri listener,让 `get_local_game_manifest` 从旧快照切换到新快照,并由非 Supervisor Agent 事件驱动资产、completed 任务、运行入口和版本卡出现;另测项目切换时旧请求迟到。旧的直接 `onManifestChange` 测试只能标记为下游桥接证据。 +- 验证:集成测试必须渲染真实 `App + WorkspaceLauncher`、捕获真实 Tauri listener,让 `get_local_game_manifest` 从旧快照切换到新快照,并由非 Supervisor Agent 事件驱动资产、completed 任务、运行入口和版本卡出现;另测项目切换时旧请求迟到。测试夹具必须先等待目标 Tauri listener 注册完成再发失效事件,并对“事件 -> manifest 重读 -> 工作台重投影”使用局部、有界的 `5_000ms` 等待,避免并行全量回归把监听注册或异步投影调度误判为功能失败。旧的直接 `onManifestChange` 测试只能标记为下游桥接证据。 ## React 资源详情焦点不能依赖重建对象身份(2026-08-05) diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 2bd338be4..3d3e069b6 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -994,9 +994,11 @@ describe('CreationLandingView', () => { 'creation-landing__asset-preview--campaign', ); expect(campaignPreview?.style.aspectRatio).toBe('900 / 1200'); - const campaignImage = await screen.findByRole('img', { - name: '活动精选', - }); + const campaignImage = await screen.findByRole( + 'img', + { name: '活动精选' }, + { timeout: 5_000 }, + ); expect((campaignImage as HTMLImageElement).src).toBe(signedCampaignUrl); expect(fetchMock).toHaveBeenCalledWith( `/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`, -- 2.52.0 From 58bf56ec56ff947eeddc9bccc20a12f7e2299e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 10:27:37 +0800 Subject: [PATCH 27/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E9=92=B1=E5=8C=85=E6=80=BB=E9=A2=9D=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 向个人中心和图片画板传递 owner 匹配的 legacy 总额 保持钱包分桶与账单明细为空避免伪造数据 补充个人中心和图片画板回归测试及契约文档 --- .../shared-memory/decision-log.md | 2 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...项目基线】当前产品与工程约束-2026-05-15.md | 2 +- .../ImageCanvasEditorView.test.tsx | 39 +++++++++++++++++++ .../image-editor/ImageCanvasEditorView.tsx | 6 ++- .../PlatformActiveProfileView.test.tsx | 5 +++ .../PlatformActiveProfileView.tsx | 7 ++++ .../PlatformEntryActiveFlowShell.test.tsx | 29 ++++++++++++-- .../PlatformEntryActiveFlowShell.tsx | 8 ++-- 9 files changed, 90 insertions(+), 10 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 01e07f9e6..5fc9863eb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6479,7 +6479,7 @@ - 并发与账号边界:同一 owner generation 同时只执行一个余额读取;读取期间的新变化通知在当前请求结束后补读,直到覆盖最后一次通知。切换或退出账号立即清空快照、提升 generation、中止并脱离旧 generation 的 active 请求,新 owner 不得等待旧 transport 收束;不响应 abort 的旧 transport 可以在后台结束,但其结果必须忽略。消费端在 owner 绑定 effect 提交前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,不能让旧余额与新账号身份同屏。充值中心读取或 mutation 响应必须携带请求开始时捕获的 owner,owner 不符时忽略。刷新失败保留已有快照;响应缺少 `mudPointBalance` 时进入错误状态,不用总额反推分桶。 - UI 与 mutation:充值 controller 继续保存商品、订单和支付状态,但充值中心响应必须同步写入共享快照,余额 mutation 应在应用响应后再通知一次合并刷新。充值弹窗的余额和分桶由当前 Store 快照覆盖。生成完成、失败退款、兑换码和邀请奖励等事件只发送余额可能变化通知;账号变化同时清理旧充值中心、账单与支付临时状态。`limitedPoints` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。 - 2026-08-05 审查补充:主站 transport adapter 必须通过既有请求 options 真实透传 `AbortSignal`;页面恢复时相邻的 `visibilitychange / focus` 合并为一次余额通知,并在卸载时清理待执行任务。Store 的 owner 输入统一在边界 trim,活动请求清理同时观察 Promise 成功与失败,不能用无人接收的 `finally` 派生 Promise。 -- 2026-08-05 账号隔离补充:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`。 +- 2026-08-05 账号隔离补充,2026-08-07 完善 legacy 总额入口:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`;owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。 - 2026-08-05 生成扣退费时序补充:external generation 入队时尚未扣费,worker 领取为 `running` 后的资产操作才预扣,业务失败则先退款再写任务失败态。主站钱包因此以账号下全局 active external task 为轮询生命周期,每轮成功状态读取都通知共享 Store 合并刷新,终态轮同时覆盖成功结算与失败退款;画布内容刷新仍只限当前项目的 `completed`,不因其它项目或 `failed` 刷新画布。 - 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index dd42aa144..18b6cc4a2 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -9,7 +9,7 @@ - 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。 - 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=`。 - 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。 -- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额优先展示画板按扣费、退回、到账和页面恢复链路刷新的 `profileDashboard.walletBalance`,充值中心 `mudPointBalance` 仅用于展开面板的账户明细,不覆盖已刷新的总余额。余额区只展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。 +- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额读取 owner 匹配的钱包 `mudPointBalance.totalPoints`;兼容响应只有 `walletBalance` 时只将该 legacy 总额传给顶部收起态展示,不写回共享钱包,也不伪造不限时、每日免费或重置分桶。余额区只在真实 `mudPointBalance` 存在时展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。 - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮二维平移、`Ctrl / Cmd + 滚轮` 缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 567cd954f..9dbee647f 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -59,7 +59,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 ## 账户与充值 -1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`;只有充值中心响应缺少 `mudPointBalance` 时,充值弹窗与空账单可以在 owner 匹配后使用同一响应的 legacy `walletBalance` 作为展示兜底,不得写回共享钱包状态。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。普通充值中心 GET 必须在发起时捕获钱包 owner 生命周期与 invalidation 版本;回包只能结算不晚于该版本的刷新,过期快照不得覆盖余额或中止更新的终态刷新。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;任务列表首次 bootstrap 的 active / terminal 任一读取瞬时失败时必须有界退避重试,成功取得全局 active ID 后再交给常规轮询,不能把首次空状态固化为停止钱包通知。公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 +1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`;只有充值中心响应缺少 `mudPointBalance` 时,充值弹窗、空账单、个人中心统计卡与图片画板顶部等只展示总额的入口可以在 owner 匹配后使用同一响应的 legacy `walletBalance` 作为展示兜底,不得写回共享钱包状态,也不得据此伪造分桶明细。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。普通充值中心 GET 必须在发起时捕获钱包 owner 生命周期与 invalidation 版本;回包只能结算不晚于该版本的刷新,过期快照不得覆盖余额或中止更新的终态刷新。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;任务列表首次 bootstrap 的 active / terminal 任一读取瞬时失败时必须有界退避重试,成功取得全局 active ID 后再交给常规轮询,不能把首次空状态固化为停止钱包通知。公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 2. 账户充值弹窗标题统一为“购买更多泥点”,当前版本只展示泥点商品,不展示会员页签、会员商品、购买会员或升级会员入口。底层会员数据与周期刷新能力继续保留用于存量兼容和结算,会员周期限时泥点不在当前版本前台展示。 3. 泥点默认商品固定为四档:`60 泥点 / ¥6`、`180 + 90 泥点 / ¥18`、`300 + 150 泥点 / ¥30`、`680 + 340 泥点 / ¥68`。`60` 档不加赠,后三档首次购买各加赠基础泥点的 `50%`;实际展示、下单校验和支付确认仍以后端返回的充值商品配置为准。 4. 首充加赠资格按泥点商品档位独立计算。用户买过 `points_180` 后,只影响 `points_180` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。 diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 139beabc6..44b75d999 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -670,6 +670,45 @@ describe('ImageCanvasEditorView', () => { expect(openAccountModal).toHaveBeenCalledTimes(1); }); + it('shows the owner-matched legacy wallet total without inventing breakdown rows', async () => { + const user = userEvent.setup(); + usePlatformWalletStore.getState().setWalletOwner('user-1'); + getPlatformProfileRechargeCenterMock.mockResolvedValue({ + walletBalance: 37, + }); + render( + + + , + ); + + const walletButton = await screen.findByRole('button', { + name: '泥点 37', + }); + await user.hover(walletButton); + + const details = await screen.findByRole('dialog', { + name: '泥点账户详情', + }); + expect(within(details).getByText('充值中心响应缺少泥点余额')).toBeTruthy(); + expect(within(details).queryByText('不限时泥点')).toBeNull(); + expect(within(details).queryByText('每日免费泥点')).toBeNull(); + }); + it('opens the shared wallet breakdown and ledger from the canvas topbar', async () => { const user = userEvent.setup(); usePlatformWalletStore.getState().setWalletOwner('user-1'); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 8175322c0..87db5b39c 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -137,6 +137,7 @@ const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [ ]; type ImageCanvasEditorViewProps = { + legacyWalletBalance?: number | null; onProjectAccessLost?: () => void; }; @@ -319,6 +320,7 @@ const DEAD_INLINE_PLACEHOLDER_NOTICE = '上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。'; export function ImageCanvasEditorView({ + legacyWalletBalance = null, onProjectAccessLost, }: ImageCanvasEditorViewProps = {}) { const authUi = useAuthUi(); @@ -352,7 +354,9 @@ export function ImageCanvasEditorView({ const mudPointBalanceError = walletOwnerMatchesCurrentUser ? storedMudPointBalanceError : ''; - const walletBalance = mudPointBalance?.totalPoints ?? null; + const walletBalance = + mudPointBalance?.totalPoints ?? + (walletOwnerMatchesCurrentUser ? legacyWalletBalance : null); const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const assetListRef = useRef(null); diff --git a/src/components/platform-entry/PlatformActiveProfileView.test.tsx b/src/components/platform-entry/PlatformActiveProfileView.test.tsx index f2942d612..080996f2d 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.test.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.test.tsx @@ -49,6 +49,7 @@ describe('PlatformActiveProfileView', () => { dashboard={null} isLoadingDashboard={false} isLoadingWalletBalance={false} + legacyWalletBalance={null} mudPointBalance={null} user={null} />, @@ -71,6 +72,7 @@ describe('PlatformActiveProfileView', () => { }} isLoadingDashboard={false} isLoadingWalletBalance={false} + legacyWalletBalance={null} mudPointBalance={{ totalPoints: 108, permanentPoints: 88, @@ -114,6 +116,7 @@ describe('PlatformActiveProfileView', () => { dashboard={null} isLoadingDashboard={false} isLoadingWalletBalance={false} + legacyWalletBalance={null} mudPointBalance={null} user={authenticatedUser} />, @@ -148,6 +151,7 @@ describe('PlatformActiveProfileView', () => { dashboard={null} isLoadingDashboard={false} isLoadingWalletBalance={false} + legacyWalletBalance={null} mudPointBalance={null} user={authenticatedUser} />, @@ -212,6 +216,7 @@ describe('PlatformActiveProfileView', () => { dashboard={null} isLoadingDashboard={false} isLoadingWalletBalance={false} + legacyWalletBalance={null} mudPointBalance={null} user={authenticatedUser} />, diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx index 27468698e..24d6c3c48 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.tsx @@ -63,6 +63,7 @@ type PlatformActiveProfileViewProps = { dashboard: ProfileDashboardSummary | null; isLoadingDashboard: boolean; isLoadingWalletBalance: boolean; + legacyWalletBalance: number | null; mudPointBalance: ProfileMudPointBalance | null; onLogin: () => void; onOpenApiKeys: () => void; @@ -245,11 +246,15 @@ function formatDashboardCount(value: number) { function formatWalletBalance( balance: ProfileMudPointBalance | null, + legacyBalance: number | null, isLoading: boolean, ) { if (balance) { return formatDashboardCount(balance.totalPoints); } + if (legacyBalance !== null) { + return formatDashboardCount(legacyBalance); + } if (isLoading) { return '读取中'; } @@ -267,6 +272,7 @@ export function PlatformActiveProfileView({ dashboard, isLoadingDashboard, isLoadingWalletBalance, + legacyWalletBalance, mudPointBalance, onLogin, onOpenApiKeys, @@ -562,6 +568,7 @@ export function PlatformActiveProfileView({ label="泥点余额" value={formatWalletBalance( mudPointBalance, + legacyWalletBalance, isLoadingWalletBalance, )} icon={Coins} diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index c57880028..b73171668 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -99,7 +99,16 @@ vi.mock('../project/ProjectGalleryView', () => ({ })); vi.mock('../image-editor/ImageCanvasEditorView', () => ({ - ImageCanvasEditorView: () =>
, + ImageCanvasEditorView: ({ + legacyWalletBalance, + }: { + legacyWalletBalance?: number | null; + }) => ( +
+ ), })); vi.mock( @@ -214,7 +223,7 @@ describe('PlatformEntryActiveFlowShell', () => { expect(screen.queryByText('999')).toBeNull(); }); - it('uses the owner-matched legacy recharge balance for an empty ledger', async () => { + it('passes the owner-matched legacy total to profile and editor while keeping the ledger empty', async () => { authUiMock.value.user = { id: 'user-1', publicUserCode: '100001', @@ -233,15 +242,29 @@ describe('PlatformEntryActiveFlowShell', () => { profileCenterMock.isWalletLedgerOpen = true; profileCenterMock.rechargeCenter = { walletBalance: 37 }; - render( + const { rerender } = render( , ); + expect( + await screen.findByRole('button', { name: '泥点余额 37' }), + ).toBeTruthy(); expect(await screen.findByText('37泥点')).toBeTruthy(); expect(screen.getByText('暂无账单记录')).toBeTruthy(); + + rerender( + , + ); + const imageEditor = await screen.findByRole('main', { + name: '图片画布编辑器', + }); + expect(imageEditor.getAttribute('data-legacy-wallet-balance')).toBe('37'); }); it('clears the previous wallet synchronously when the authenticated account changes', async () => { diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index bd3a83361..e34957980 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -287,6 +287,9 @@ export function PlatformEntryFlowShellImpl({ requestLogin: () => authUi?.openLoginModal(), currentUser: authUi?.user, }); + const legacyWalletBalance = walletOwnerMatchesCurrentUser + ? (profileCenter.rechargeCenter?.walletBalance ?? null) + : null; const openCreation = useCallback(() => { if (!isDesktopLayout) { @@ -362,6 +365,7 @@ export function PlatformEntryFlowShellImpl({
}> @@ -370,9 +374,6 @@ export function PlatformEntryFlowShellImpl({ } const isAuthenticated = Boolean(authUi?.user); - const legacyWalletBalance = walletOwnerMatchesCurrentUser - ? (profileCenter.rechargeCenter?.walletBalance ?? null) - : null; const balance = mudPointBalance?.totalPoints ?? legacyWalletBalance; const isCreationStage = !isProfileStage && @@ -543,6 +544,7 @@ export function PlatformEntryFlowShellImpl({ dashboard={dashboard} isLoadingDashboard={isLoadingDashboard} isLoadingWalletBalance={mudPointBalanceStatus === 'loading'} + legacyWalletBalance={legacyWalletBalance} mudPointBalance={mudPointBalance} user={authUi?.user} onLogin={() => authUi?.openLoginModal()} -- 2.52.0 From 098009a701ea06751331a6ea09b442da32e97a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 11:00:04 +0800 Subject: [PATCH 28/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=92=B1=E5=8C=85?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E8=BE=B9=E7=95=8C=E4=B8=8E=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E9=99=88=E6=97=A7=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 隔离账号切换后的充值响应并中止失效支付监听 防止乱序钱包快照回滚并保留旧版总额兜底 清理任务重试耗尽后的陈旧状态并补充回归测试 稳定测试夹具、用户错误提示与首帧加载状态 同步单一钱包 Store 的长期决策记录 --- .../src/features/app-shell/AccountWallet.tsx | 11 ++- .../features/app-shell/useAccountWallet.ts | 73 ++++++++++++++----- .../tests/walletStore.test.ts | 11 ++- .../shared-memory/decision-log.md | 1 + .../stores/createProfileWalletStore.test.ts | 41 ++++++++++- .../src/stores/createProfileWalletStore.ts | 25 ++++++- .../ImageCanvasTaskSidebarView.test.tsx | 60 +++++++++++++++ .../ImageCanvasTaskSidebarView.tsx | 10 ++- .../PlatformEntryActiveFlowShell.test.tsx | 3 +- .../PlatformEntryActiveFlowShell.tsx | 19 +++-- ...ormProfileCenterController.testSupport.tsx | 21 +++++- .../usePlatformProfileCenterController.ts | 63 ++++++++++------ 12 files changed, 273 insertions(+), 65 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx b/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx index e52df521a..3a9933491 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx @@ -11,7 +11,10 @@ export function AccountWalletBar({ return (
controller.setWalletLedgerOpen(false)} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 07a633e5f..c4806f4f1 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { PlatformProfileRechargeNativePaymentState } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal'; import type { @@ -19,6 +19,7 @@ export function useAccountWallet(currentUserId: string) { const { ownerUserId, mudPointBalance, + legacyWalletBalance, mudPointBalanceStatus, mudPointBalanceError, setWalletOwner, @@ -47,7 +48,6 @@ export function useAccountWallet(currentUserId: string) { const walletLedgerLifecycleRef = useRef(0); const [walletUiOwnerUserId, setWalletUiOwnerUserId] = useState(currentUserId); const currentUserIdRef = useRef(currentUserId); - currentUserIdRef.current = currentUserId; const walletOwnerMatchesCurrentUser = Boolean(currentUserId) && ownerUserId === currentUserId; const walletUiOwnerMatchesCurrentUser = @@ -57,12 +57,23 @@ export function useAccountWallet(currentUserId: string) { const visibleMudPointBalance = walletOwnerMatchesCurrentUser ? mudPointBalance : null; + const visibleLegacyWalletBalance = walletOwnerMatchesCurrentUser + ? legacyWalletBalance + : null; const visibleMudPointBalanceStatus = walletOwnerMatchesCurrentUser ? mudPointBalanceStatus : 'idle'; const visibleMudPointBalanceError = walletOwnerMatchesCurrentUser ? mudPointBalanceError : ''; + const visibleWalletBalance = + visibleMudPointBalance?.totalPoints ?? visibleLegacyWalletBalance; + + useLayoutEffect(() => { + currentUserIdRef.current = currentUserId; + rechargeLifecycleRef.current += 1; + walletLedgerLifecycleRef.current += 1; + }, [currentUserId]); useEffect(() => { setWalletOwner(currentUserId || null); @@ -82,8 +93,6 @@ export function useAccountWallet(currentUserId: string) { }, [currentUserId, onWalletBalanceMayHaveChanged, setWalletOwner]); useEffect(() => { - rechargeLifecycleRef.current += 1; - walletLedgerLifecycleRef.current += 1; setWalletUiOwnerUserId(currentUserId); setWalletLedgerOpen(false); setWalletLedger(null); @@ -162,18 +171,27 @@ export function useAccountWallet(currentUserId: string) { setRechargeError(null); try { const center = await getClientProfileRechargeCenter(); - if (rechargeLifecycleRef.current !== rechargeLifecycle) { + if ( + rechargeLifecycleRef.current !== rechargeLifecycle || + currentUserIdRef.current !== snapshotOwnerUserId + ) { return; } applyRechargeContent(walletSnapshot, center); } catch (error) { - if (rechargeLifecycleRef.current === rechargeLifecycle) { + if ( + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { setRechargeError( error instanceof Error ? error.message : '读取泥点购买信息失败', ); } } finally { - if (rechargeLifecycleRef.current === rechargeLifecycle) { + if ( + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { setRechargeLoading(false); } } @@ -198,14 +216,18 @@ export function useAccountWallet(currentUserId: string) { return; } const rechargeLifecycle = rechargeLifecycleRef.current; - const walletSnapshot = captureWalletBalanceSnapshot(currentUserId); + const snapshotOwnerUserId = currentUserId; + const walletSnapshot = captureWalletBalanceSnapshot(snapshotOwnerUserId); setSubmittingRechargeProductId(product.productId); setRechargeError(null); try { const response = await createClientProfileRechargeOrder( product.productId, ); - if (rechargeLifecycleRef.current !== rechargeLifecycle) { + if ( + rechargeLifecycleRef.current !== rechargeLifecycle || + currentUserIdRef.current !== snapshotOwnerUserId + ) { return; } applyRechargeContent(walletSnapshot, response.center); @@ -224,11 +246,17 @@ export function useAccountWallet(currentUserId: string) { isConfirming: false, }); } catch (error) { - if (rechargeLifecycleRef.current === rechargeLifecycle) { + if ( + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { setRechargeError(error instanceof Error ? error.message : '充值失败'); } } finally { - if (rechargeLifecycleRef.current === rechargeLifecycle) { + if ( + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { setSubmittingRechargeProductId(null); } } @@ -239,7 +267,8 @@ export function useAccountWallet(currentUserId: string) { return; } const rechargeLifecycle = rechargeLifecycleRef.current; - const walletSnapshot = captureWalletBalanceSnapshot(currentUserId); + const snapshotOwnerUserId = currentUserId; + const walletSnapshot = captureWalletBalanceSnapshot(snapshotOwnerUserId); const orderId = nativeRechargePayment.orderId; setNativeRechargePayment((current) => current?.orderId === orderId @@ -248,7 +277,10 @@ export function useAccountWallet(currentUserId: string) { ); try { const response = await confirmClientWechatProfileRechargeOrder(orderId); - if (rechargeLifecycleRef.current !== rechargeLifecycle) { + if ( + rechargeLifecycleRef.current !== rechargeLifecycle || + currentUserIdRef.current !== snapshotOwnerUserId + ) { return; } applyRechargeContent(walletSnapshot, response.center); @@ -269,7 +301,10 @@ export function useAccountWallet(currentUserId: string) { : current, ); } catch { - if (rechargeLifecycleRef.current === rechargeLifecycle) { + if ( + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { setNativeRechargePayment((current) => current?.orderId === orderId ? { @@ -286,6 +321,7 @@ export function useAccountWallet(currentUserId: string) { return { ownerUserId, mudPointBalance: visibleMudPointBalance, + legacyWalletBalance: visibleLegacyWalletBalance, mudPointBalanceStatus: visibleMudPointBalanceStatus, mudPointBalanceError: visibleMudPointBalanceError, onWalletBalanceMayHaveChanged, @@ -297,11 +333,14 @@ export function useAccountWallet(currentUserId: string) { walletLedgerError: walletUiIsVisible ? walletLedgerError : null, rechargeOpen: walletUiIsVisible && rechargeOpen, rechargeModalCenter: - rechargeContent && visibleMudPointBalance + rechargeContent && + visibleWalletBalance !== null ? { ...rechargeContent, - walletBalance: visibleMudPointBalance.totalPoints, - mudPointBalance: visibleMudPointBalance, + walletBalance: visibleWalletBalance, + ...(visibleMudPointBalance + ? { mudPointBalance: visibleMudPointBalance } + : {}), } : null, rechargeLoading: walletUiIsVisible && rechargeLoading, diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index 008963d36..c5449723b 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -36,7 +36,7 @@ describe('useWalletStore', () => { vi.clearAllMocks(); }); - test('keeps mudPointBalance as the only balance state', async () => { + test('keeps mudPointBalance as the authoritative detailed balance state', async () => { const mudPointBalance = detailedBalance(120); clientApi.getClientProfileRechargeCenter.mockResolvedValue({ walletBalance: 120, @@ -49,11 +49,13 @@ describe('useWalletStore', () => { }); expect(result.current.mudPointBalance).toEqual(mudPointBalance); + expect(result.current.legacyWalletBalance).toBe(120); expect(result.current.mudPointBalanceStatus).toBe('ready'); expect(result.current.mudPointBalanceError).toBe(''); expect(Object.keys(result.current).sort()).toEqual([ 'applyWalletBalanceSnapshot', 'captureWalletBalanceSnapshot', + 'legacyWalletBalance', 'mudPointBalance', 'mudPointBalanceError', 'mudPointBalanceStatus', @@ -116,9 +118,10 @@ describe('useWalletStore', () => { await useWalletStore.getState().onWalletBalanceMayHaveChanged(); expect(useWalletStore.getState().mudPointBalance).toBeNull(); + expect(useWalletStore.getState().legacyWalletBalance).toBe(120); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error'); expect(useWalletStore.getState().mudPointBalanceError).toBe( - '充值中心响应缺少泥点余额', + '泥点明细读取失败', ); }); @@ -178,7 +181,9 @@ describe('useWalletStore', () => { expect(useWalletStore.getState().mudPointBalance?.totalPoints).toBe(90); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error'); - expect(useWalletStore.getState().mudPointBalanceError).toBe('刷新失败'); + expect(useWalletStore.getState().mudPointBalanceError).toBe( + '泥点明细读取失败', + ); }); test('does not restore a late response after reset', async () => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5fc9863eb..100d19bba 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6480,6 +6480,7 @@ - UI 与 mutation:充值 controller 继续保存商品、订单和支付状态,但充值中心响应必须同步写入共享快照,余额 mutation 应在应用响应后再通知一次合并刷新。充值弹窗的余额和分桶由当前 Store 快照覆盖。生成完成、失败退款、兑换码和邀请奖励等事件只发送余额可能变化通知;账号变化同时清理旧充值中心、账单与支付临时状态。`limitedPoints` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。 - 2026-08-05 审查补充:主站 transport adapter 必须通过既有请求 options 真实透传 `AbortSignal`;页面恢复时相邻的 `visibilitychange / focus` 合并为一次余额通知,并在卸载时清理待执行任务。Store 的 owner 输入统一在边界 trim,活动请求清理同时观察 Promise 成功与失败,不能用无人接收的 `finally` 派生 Promise。 - 2026-08-05 账号隔离补充,2026-08-07 完善 legacy 总额入口:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`;owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。 +- 2026-08-07 审查收口补充:共享 Store 显式保存 owner 隔离的 `legacyWalletBalance`,确保首次生命周期读取旧响应时无需先打开充值弹窗即可展示纯总额;该字段不能生成 `mudPointBalance` 分桶。直接余额快照附带单调 operation sequence,后发操作先落地后拒绝更早快照回滚。刷新错误只向 UI 暴露稳定中文提示,不透传 transport / 后端实现文案。 - 2026-08-05 生成扣退费时序补充:external generation 入队时尚未扣费,worker 领取为 `running` 后的资产操作才预扣,业务失败则先退款再写任务失败态。主站钱包因此以账号下全局 active external task 为轮询生命周期,每轮成功状态读取都通知共享 Store 合并刷新,终态轮同时覆盖成功结算与失败退款;画布内容刷新仍只限当前项目的 `completed`,不因其它项目或 `failed` 刷新画布。 - 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 diff --git a/packages/shared/src/stores/createProfileWalletStore.test.ts b/packages/shared/src/stores/createProfileWalletStore.test.ts index d883685a8..bb55c76f4 100644 --- a/packages/shared/src/stores/createProfileWalletStore.test.ts +++ b/packages/shared/src/stores/createProfileWalletStore.test.ts @@ -156,7 +156,7 @@ describe('createProfileWalletStore', () => { expect(store.getState()).toMatchObject({ mudPointBalance: balance(55), mudPointBalanceStatus: 'error', - mudPointBalanceError: 'network down', + mudPointBalanceError: '泥点明细读取失败', }); }); @@ -173,7 +173,7 @@ describe('createProfileWalletStore', () => { expect(store.getState()).toMatchObject({ mudPointBalance: null, mudPointBalanceStatus: 'error', - mudPointBalanceError: '充值中心响应缺少泥点余额', + mudPointBalanceError: '泥点明细读取失败', }); }); @@ -235,6 +235,43 @@ describe('createProfileWalletStore', () => { 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('rejects a snapshot after switching away from and back to the same owner', () => { const store = createProfileWalletStore({ getRechargeCenter: vi.fn() }); store.getState().setWalletOwner('user-a'); diff --git a/packages/shared/src/stores/createProfileWalletStore.ts b/packages/shared/src/stores/createProfileWalletStore.ts index 9b1f8a787..9d834ff70 100644 --- a/packages/shared/src/stores/createProfileWalletStore.ts +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -17,11 +17,13 @@ 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; @@ -38,6 +40,7 @@ export type ProfileWalletStore = { const EMPTY_WALLET_STATE = { mudPointBalance: null, + legacyWalletBalance: null, mudPointBalanceStatus: 'idle', mudPointBalanceError: '', } as const; @@ -48,6 +51,8 @@ export function createProfileWalletStore( let refreshVersion = 0; let settledRefreshVersion = 0; let ownerVersion = 0; + let operationSequence = 0; + let latestAppliedOperationSequence = 0; let requestGeneration = 0; let activeRefresh: Promise | null = null; let activeAbortController: AbortController | null = null; @@ -69,6 +74,7 @@ export function createProfileWalletStore( } ownerVersion += 1; + latestAppliedOperationSequence = 0; invalidateActiveRefresh(); settledRefreshVersion = refreshVersion; set({ @@ -89,6 +95,7 @@ export function createProfileWalletStore( ownerUserId: normalizedOwnerUserId, ownerVersion, invalidationVersion: refreshVersion, + operationSequence: ++operationSequence, }; }, applyWalletBalanceSnapshot: (snapshot, balance) => { @@ -96,15 +103,21 @@ export function createProfileWalletStore( get().ownerUserId !== snapshot.ownerUserId || ownerVersion !== snapshot.ownerVersion || snapshot.invalidationVersion < refreshVersion || - snapshot.invalidationVersion < settledRefreshVersion + 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: '', }); @@ -138,16 +151,20 @@ export function createProfileWalletStore( continue; } if (!center.mudPointBalance) { + if (Number.isFinite(center.walletBalance)) { + set({ legacyWalletBalance: center.walletBalance }); + } throw new Error('充值中心响应缺少泥点余额'); } settledRefreshVersion = requestedVersion; set({ mudPointBalance: center.mudPointBalance, + legacyWalletBalance: center.mudPointBalance.totalPoints, mudPointBalanceStatus: 'ready', mudPointBalanceError: '', }); - } catch (error) { + } catch { if (generation !== requestGeneration) { return; } @@ -159,8 +176,7 @@ export function createProfileWalletStore( settledRefreshVersion = requestedVersion; set({ mudPointBalanceStatus: 'error', - mudPointBalanceError: - error instanceof Error ? error.message : '泥点明细读取失败', + mudPointBalanceError: '泥点明细读取失败', }); } } @@ -180,6 +196,7 @@ export function createProfileWalletStore( }, resetWalletBalance: () => { ownerVersion += 1; + latestAppliedOperationSequence = 0; invalidateActiveRefresh(); settledRefreshVersion = refreshVersion; set({ ownerUserId: null, ...EMPTY_WALLET_STATE }); diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index ec3a6345b..d4623874d 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -715,6 +715,66 @@ describe('ImageCanvasTaskSidebarView', () => { } }); + it('clears stale tasks after a refreshed bootstrap exhausts its retries', async () => { + vi.useFakeTimers(); + try { + const staleTask = createExternalTask({ + jobId: 'stale-running-task', + requestLabel: '旧生成任务', + status: 'running', + }); + listExternalGenerationTasksMock.mockImplementation( + ( + options: Parameters[0] = {}, + ) => + Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: options.statuses?.includes('running') ? 1 : 0, + unacknowledgedTerminalCount: 0, + updatedAtMicros: 1, + }, + tasks: options.statuses?.includes('running') ? [staleTask] : [], + }), + ); + + const { rerender } = render( + , + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText('旧生成任务')).toBeTruthy(); + + listExternalGenerationTasksMock.mockRejectedValue( + new Error('task list unavailable'), + ); + rerender( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + await Promise.resolve(); + }); + + expect(screen.queryByText('旧生成任务')).toBeNull(); + expect(screen.getByText('暂无任务')).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); + it('aborts every bootstrap attempt when one branch fails and after retry unmounts', async () => { vi.useFakeTimers(); try { diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx index 835fb1c5a..c81b03e62 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -413,10 +413,12 @@ export function ImageCanvasTaskSidebarView({ if (controller === attemptController) { controller = null; } - if ( - disposed || - retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length - ) { + if (disposed) { + return; + } + if (retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length) { + setExternalTasks([]); + setWalletActiveExternalTaskIds([]); return; } const retryDelayMs = TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS[retryIndex]; diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index b73171668..9aabab9b7 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -223,7 +223,7 @@ describe('PlatformEntryActiveFlowShell', () => { expect(screen.queryByText('999')).toBeNull(); }); - it('passes the owner-matched legacy total to profile and editor while keeping the ledger empty', async () => { + it('passes the lifecycle legacy total to profile and editor without opening recharge', async () => { authUiMock.value.user = { id: 'user-1', publicUserCode: '100001', @@ -240,7 +240,6 @@ describe('PlatformEntryActiveFlowShell', () => { walletBalance: 37, }); profileCenterMock.isWalletLedgerOpen = true; - profileCenterMock.rechargeCenter = { walletBalance: 37 }; const { rerender } = render( state.mudPointBalance, ); + const storedLegacyWalletBalance = usePlatformWalletStore( + (state) => state.legacyWalletBalance, + ); const storedMudPointBalanceStatus = usePlatformWalletStore( (state) => state.mudPointBalanceStatus, ); @@ -252,12 +255,14 @@ export function PlatformEntryFlowShellImpl({ const mudPointBalance = walletOwnerMatchesCurrentUser ? storedMudPointBalance : null; - const mudPointBalanceStatus = walletOwnerMatchesCurrentUser - ? storedMudPointBalanceStatus - : 'idle'; const mudPointBalanceError = walletOwnerMatchesCurrentUser ? storedMudPointBalanceError : ''; + const isWalletBalanceLoading = + Boolean(currentWalletOwnerUserId) && + (!walletOwnerMatchesCurrentUser || + storedMudPointBalanceStatus === 'idle' || + storedMudPointBalanceStatus === 'loading'); const refreshDashboard = useCallback(async () => { if (!authUi?.user || !authUi.canAccessProtectedData) { @@ -288,7 +293,9 @@ export function PlatformEntryFlowShellImpl({ currentUser: authUi?.user, }); const legacyWalletBalance = walletOwnerMatchesCurrentUser - ? (profileCenter.rechargeCenter?.walletBalance ?? null) + ? (storedLegacyWalletBalance ?? + profileCenter.rechargeCenter?.walletBalance ?? + null) : null; const openCreation = useCallback(() => { @@ -489,7 +496,7 @@ export function PlatformEntryFlowShellImpl({ variant={isDesktopLayout ? 'desktop' : 'mobile'} balance={balance} breakdown={mudPointBalance} - isLoading={mudPointBalanceStatus === 'loading'} + isLoading={isWalletBalanceLoading} error={mudPointBalanceError || null} className={ isDesktopLayout @@ -543,7 +550,7 @@ export function PlatformEntryFlowShellImpl({ ; + +export const userA = { ...baseUser, id: 'user-a' } satisfies AuthUser; +export const userB = { + ...baseUser, + id: 'user-b', + publicUserCode: '100002', +} satisfies AuthUser; export function renderController( currentUser: AuthUser, onRechargeSuccess = vi.fn(), ) { + const requestLogin = vi.fn(); return renderHook( ({ user }) => usePlatformProfileCenterController({ @@ -79,7 +94,7 @@ export function renderController( isAuthenticated: true, showRechargeEntry: true, onRechargeSuccess, - requestLogin: vi.fn(), + requestLogin, currentUser: user, }), { initialProps: { user: currentUser } }, diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 1da0f2634..4254a5203 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -61,11 +61,7 @@ type WechatPayResult = { }; type RechargePaymentResultKind = - | 'success' - | 'pending' - | 'cancel' - | 'failed' - | 'expired'; + 'success' | 'pending' | 'cancel' | 'failed' | 'expired'; export type RechargePaymentResult = { kind: RechargePaymentResultKind; @@ -971,22 +967,23 @@ export function usePlatformProfileCenterController({ }, [openRechargeModal, openRewardCodeModal, showRechargeEntry]); const closeNativeWechatPayment = useCallback(() => { - setNativeWechatPayment((current) => { - if (current?.isConfirming) { - return current; - } - const pendingOrder = pendingWechatRechargeOrderRef.current; - if ( - current && - pendingOrder?.orderId === current.orderId && - isRechargeOrderCurrent(pendingOrder) - ) { - pendingOrder.abortController.abort(); - pendingWechatRechargeOrderRef.current = null; - } - return null; - }); - }, [isRechargeOrderCurrent]); + if (!nativeWechatPayment || nativeWechatPayment.isConfirming) { + return; + } + const pendingOrder = pendingWechatRechargeOrderRef.current; + if ( + pendingOrder?.orderId === nativeWechatPayment.orderId && + isRechargeOrderCurrent(pendingOrder) + ) { + pendingOrder.abortController.abort(); + pendingWechatRechargeOrderRef.current = null; + } + setNativeWechatPayment((current) => + current?.orderId === nativeWechatPayment.orderId && !current.isConfirming + ? null + : current, + ); + }, [isRechargeOrderCurrent, nativeWechatPayment]); const buyRechargeProduct = useCallback( (product: ProfileRechargeProduct) => { @@ -1363,7 +1360,24 @@ export function usePlatformProfileCenterController({ } let cancelled = false; - const confirmationOptions = createRechargeConfirmationOptions(order); + const orderConfirmationOptions = createRechargeConfirmationOptions(order); + const effectAbortController = new AbortController(); + const abortEffectRequest = () => { + effectAbortController.abort(orderConfirmationOptions.signal.reason); + }; + if (orderConfirmationOptions.signal.aborted) { + abortEffectRequest(); + } else { + orderConfirmationOptions.signal.addEventListener( + 'abort', + abortEffectRequest, + { once: true }, + ); + } + const confirmationOptions = { + ...orderConfirmationOptions, + signal: effectAbortController.signal, + }; const watchUntilSettled = async () => { while ( !cancelled && @@ -1441,6 +1455,11 @@ export function usePlatformProfileCenterController({ void watchUntilSettled(); return () => { cancelled = true; + orderConfirmationOptions.signal.removeEventListener( + 'abort', + abortEffectRequest, + ); + effectAbortController.abort(); }; }, [ nativeWechatPayment?.expiresAt, -- 2.52.0 From dee257c16c3668f73618c72390e3efb8f84a6013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 11:23:18 +0800 Subject: [PATCH 29/36] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E6=A0=B9=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E9=92=B1=E5=8C=85=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将主站唯一钱包 lifecycle 上移到 AuthGate 根认证边界 在账号切换、StrictMode 重放和根卸载时统一清空钱包 Store 移除平台子壳的重复 owner 声明并补充测试边界 新增 mount、focus、unmount 与 StrictMode 回归测试 同步钱包 lifecycle 长期决策与 Vitest 收集范围 --- .../shared-memory/decision-log.md | 1 + src/components/auth/AuthGate.test.tsx | 54 +++++++++++- src/components/auth/AuthGate.tsx | 6 ++ .../PlatformEntryActiveFlowShell.test.tsx | 27 +++++- .../PlatformEntryActiveFlowShell.tsx | 9 +- src/stores/usePlatformWalletStore.test.tsx | 85 +++++++++++++++++++ src/stores/usePlatformWalletStore.ts | 11 ++- vitest.config.ts | 2 + 8 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 src/stores/usePlatformWalletStore.test.tsx diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 100d19bba..cbc5f2283 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6481,6 +6481,7 @@ - 2026-08-05 审查补充:主站 transport adapter 必须通过既有请求 options 真实透传 `AbortSignal`;页面恢复时相邻的 `visibilitychange / focus` 合并为一次余额通知,并在卸载时清理待执行任务。Store 的 owner 输入统一在边界 trim,活动请求清理同时观察 Promise 成功与失败,不能用无人接收的 `finally` 派生 Promise。 - 2026-08-05 账号隔离补充,2026-08-07 完善 legacy 总额入口:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`;owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。 - 2026-08-07 审查收口补充:共享 Store 显式保存 owner 隔离的 `legacyWalletBalance`,确保首次生命周期读取旧响应时无需先打开充值弹窗即可展示纯总额;该字段不能生成 `mudPointBalance` 分桶。直接余额快照附带单调 operation sequence,后发操作先落地后拒绝更早快照回滚。刷新错误只向 UI 暴露稳定中文提示,不透传 transport / 后端实现文案。 +- 2026-08-07 lifecycle 所有权补充:主站钱包坚持全应用唯一 lifecycle,由 `AuthGate` 根认证边界绑定 ready user;现役平台壳、图片编辑器和 profile controller 只消费 Store,不重复声明 owner。根边界在账号失效、依赖切换、StrictMode effect replay 和最终卸载 cleanup 时统一 `resetWalletBalance`,中止活动请求并清除 owner、明细和 legacy 总额;禁止在子页面按相同 user ID 各自清理 module-level Store。 - 2026-08-05 生成扣退费时序补充:external generation 入队时尚未扣费,worker 领取为 `running` 后的资产操作才预扣,业务失败则先退款再写任务失败态。主站钱包因此以账号下全局 active external task 为轮询生命周期,每轮成功状态读取都通知共享 Store 合并刷新,终态轮同时覆盖成功结算与失败退款;画布内容刷新仍只限当前项目的 `completed`,不因其它项目或 `failed` 刷新画布。 - 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 11cdae721..1c385e332 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -2,7 +2,7 @@ import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useEffect, useState } from 'react'; +import { StrictMode, useEffect, useState } from 'react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { AuthSessionSummary, AuthUser } from '../../services/authService'; @@ -16,6 +16,26 @@ import { useAuthUi } from './AuthUiContext'; const browserReloadMock = vi.hoisted(() => vi.fn()); +const walletLifecycleMocks = vi.hoisted(() => ({ + usePlatformWalletLifecycle: vi.fn(), +})); + +function createMemoryStorage(): Storage { + const values = new Map(); + return { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => Array.from(values.keys())[index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, String(value)), + }; +} + +const memoryLocalStorage = createMemoryStorage(); + const authMocks = vi.hoisted(() => ({ authEntry: vi.fn(), changePassword: vi.fn(), @@ -77,6 +97,8 @@ vi.mock('../../services/authService', () => ({ startWechatLogin: authMocks.startWechatLogin, })); +vi.mock('../../stores/usePlatformWalletStore', () => walletLifecycleMocks); + const hostBridgeMocks = vi.hoisted(() => ({ getHostRuntime: vi.fn(() => ({ kind: 'browser', @@ -136,6 +158,10 @@ const mockUser: AuthUser = { beforeEach(() => { vi.clearAllMocks(); + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: memoryLocalStorage, + }); window.localStorage.clear(); window.history.replaceState(null, '', '/'); setAuthGateReloadForTest(vi.fn()); @@ -381,6 +407,32 @@ test('auth gate keeps a valid local token login when refresh rotation fails afte expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); }); +test('auth root binds the single wallet lifecycle under StrictMode', async () => { + authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token'); + authMocks.refreshStoredAccessToken.mockRejectedValue( + new Error('refresh cookie 失效'), + ); + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: mockUser, + availableLoginMethods: ['phone'], + }); + + render( + + + + + , + ); + + expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy(); + await waitFor(() => { + expect( + walletLifecycleMocks.usePlatformWalletLifecycle, + ).toHaveBeenCalledWith('user-1', true); + }); +}); + test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => { authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: [], diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 80331e5a5..e9cb49d95 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -50,6 +50,7 @@ import { reloadHostWebView, requestHostLogin, } from '../../services/host-bridge/hostBridge'; +import { usePlatformWalletLifecycle } from '../../stores/usePlatformWalletStore'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { AccountModal } from './AccountModal'; import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext'; @@ -687,6 +688,11 @@ export function AuthGate({ children }: AuthGateProps) { ], ); + usePlatformWalletLifecycle( + readyUser?.id ?? null, + status === 'ready' && Boolean(readyUser), + ); + if (status === 'checking' && !canKeepPlatformContentMounted) { return (
({ }), })); +function AuthWalletLifecycleTestBoundary({ + children, +}: { + children: ReactNode; +}) { + usePlatformWalletLifecycle( + authUiMock.value.user?.id ?? null, + authUiMock.value.canAccessProtectedData, + ); + return children; +} + +function render(ui: ReactElement) { + return testingLibraryRender(ui, { + wrapper: AuthWalletLifecycleTestBoundary, + }); +} + function StatefulPlatformEntryFlowShell({ initialStage, }: { diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 9eea741cc..32310a11d 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -25,10 +25,7 @@ import { replaceAppHistoryPath, } from '../../routing/activeAppPageRoutes'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; -import { - usePlatformWalletLifecycle, - usePlatformWalletStore, -} from '../../stores/usePlatformWalletStore'; +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; import { useAuthUi } from '../auth/AuthUiContext'; import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel'; import { PlatformActionButton } from '../common/PlatformActionButton'; @@ -230,10 +227,6 @@ export function PlatformEntryFlowShellImpl({ 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, ); diff --git a/src/stores/usePlatformWalletStore.test.tsx b/src/stores/usePlatformWalletStore.test.tsx new file mode 100644 index 000000000..2b133a265 --- /dev/null +++ b/src/stores/usePlatformWalletStore.test.tsx @@ -0,0 +1,85 @@ +/* @vitest-environment jsdom */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { type ReactNode, StrictMode } from 'react'; +import { beforeEach, expect, test, vi } from 'vitest'; + +const profileClientMocks = vi.hoisted(() => ({ + getPlatformProfileRechargeCenter: vi.fn(), +})); + +vi.mock('../services/platform-entry/platformProfileClient', () => ({ + getPlatformProfileRechargeCenter: + profileClientMocks.getPlatformProfileRechargeCenter, +})); + +import { + usePlatformWalletLifecycle, + usePlatformWalletStore, +} from './usePlatformWalletStore'; + +beforeEach(() => { + vi.clearAllMocks(); + usePlatformWalletStore.getState().resetWalletBalance(); + profileClientMocks.getPlatformProfileRechargeCenter.mockResolvedValue({ + walletBalance: 42, + mudPointBalance: { + totalPoints: 42, + permanentPoints: 42, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-08T00:00:00+08:00', + }, + }); +}); + +test('the single lifecycle survives StrictMode replay and resets on root unmount', async () => { + const addWindowListener = vi.spyOn(window, 'addEventListener'); + const removeWindowListener = vi.spyOn(window, 'removeEventListener'); + const { unmount } = renderHook( + () => usePlatformWalletLifecycle('user-1', true), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + + await waitFor(() => { + expect(usePlatformWalletStore.getState()).toMatchObject({ + ownerUserId: 'user-1', + mudPointBalance: expect.objectContaining({ totalPoints: 42 }), + legacyWalletBalance: 42, + mudPointBalanceStatus: 'ready', + }); + }); + + const requestsBeforeFocus = + profileClientMocks.getPlatformProfileRechargeCenter.mock.calls.length; + await act(async () => { + window.dispatchEvent(new Event('focus')); + }); + await waitFor(() => { + expect( + profileClientMocks.getPlatformProfileRechargeCenter.mock.calls.length, + ).toBeGreaterThan(requestsBeforeFocus); + }); + + unmount(); + + expect(usePlatformWalletStore.getState()).toMatchObject({ + ownerUserId: null, + mudPointBalance: null, + legacyWalletBalance: null, + mudPointBalanceStatus: 'idle', + }); + expect( + addWindowListener.mock.calls.filter(([eventName]) => eventName === 'focus'), + ).toHaveLength( + removeWindowListener.mock.calls.filter( + ([eventName]) => eventName === 'focus', + ).length, + ); +}); diff --git a/src/stores/usePlatformWalletStore.ts b/src/stores/usePlatformWalletStore.ts index d4b40e868..6d6da60f4 100644 --- a/src/stores/usePlatformWalletStore.ts +++ b/src/stores/usePlatformWalletStore.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { createProfileWalletStore } from '@/packages/shared/src'; +import { createProfileWalletStore } from '@/packages/shared/src/stores/createProfileWalletStore'; import { getPlatformProfileRechargeCenter } from '@/src/services/platform-entry/platformProfileClient.ts'; export const usePlatformWalletStore = createProfileWalletStore({ @@ -17,13 +17,18 @@ export function usePlatformWalletLifecycle( const onWalletBalanceMayHaveChanged = usePlatformWalletStore( (state) => state.onWalletBalanceMayHaveChanged, ); + const resetWalletBalance = usePlatformWalletStore( + (state) => state.resetWalletBalance, + ); useEffect(() => { const ownerUserId = canAccessProtectedData && currentUserId ? currentUserId : null; setWalletOwner(ownerUserId); if (!ownerUserId) { - return undefined; + return () => { + resetWalletBalance(); + }; } void onWalletBalanceMayHaveChanged(); @@ -51,11 +56,13 @@ export function usePlatformWalletLifecycle( if (foregroundRefreshTimer !== null) { window.clearTimeout(foregroundRefreshTimer); } + resetWalletBalance(); }; }, [ canAccessProtectedData, currentUserId, onWalletBalanceMayHaveChanged, + resetWalletBalance, setWalletOwner, ]); } diff --git a/vitest.config.ts b/vitest.config.ts index f811c433b..9ee1ef4ba 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -48,6 +48,8 @@ export default defineConfig({ 'src/services/external-generation/**/*.test.ts', 'src/services/payment/**/*.test.ts', 'src/services/platform-entry/platformProfileClient.test.ts', + 'src/stores/**/*.test.ts', + 'src/stores/**/*.test.tsx', 'src/components/auth/**/*.test.ts', 'src/components/auth/**/*.test.tsx', 'src/components/creation-home/**/*.test.ts', -- 2.52.0 From 121babbc195d28f532511f8a01a89ea6c026c635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 12:09:55 +0800 Subject: [PATCH 30/36] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=94=BB=E6=9D=BF?= =?UTF-8?q?=E9=92=B1=E5=8C=85=E9=94=99=E8=AF=AF=E6=96=87=E6=A1=88=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新旧版钱包总额场景的用户可见错误断言 补充内部充值响应错误不外显的回归断言 --- src/components/image-editor/ImageCanvasEditorView.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 44b75d999..cc5fc03a0 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -704,7 +704,8 @@ describe('ImageCanvasEditorView', () => { const details = await screen.findByRole('dialog', { name: '泥点账户详情', }); - expect(within(details).getByText('充值中心响应缺少泥点余额')).toBeTruthy(); + expect(within(details).getByText('泥点明细读取失败')).toBeTruthy(); + expect(within(details).queryByText('充值中心响应缺少泥点余额')).toBeNull(); expect(within(details).queryByText('不限时泥点')).toBeNull(); expect(within(details).queryByText('每日免费泥点')).toBeNull(); }); -- 2.52.0 From e4d570e5d19d9135e63c6ff91d3d70d727ec7bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 13:17:44 +0800 Subject: [PATCH 31/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=92=B1=E5=8C=85?= =?UTF-8?q?=E4=B8=8E=E8=B4=A6=E5=8F=B7=E5=BC=82=E6=AD=A5=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 legacy 余额回退与编辑器加载态 保留任务列表局部成功结果并持续钱包轮询 隔离邀请码异步状态并防止重复下单 补齐支付响应快照时序与定向回归测试 同步钱包与支付生命周期文档 --- .../features/app-shell/useAccountWallet.ts | 8 +- .../tests/walletStore.test.ts | 29 +++++ .../shared-memory/decision-log.md | 2 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...项目基线】当前产品与工程约束-2026-05-15.md | 4 +- .../stores/createProfileWalletStore.test.ts | 45 +++++++ .../src/stores/createProfileWalletStore.ts | 35 ++++- .../ImageCanvasEditorView.test.tsx | 36 +++++- .../image-editor/ImageCanvasEditorView.tsx | 17 ++- .../ImageCanvasTaskSidebarView.test.tsx | 61 ++++++++- .../ImageCanvasTaskSidebarView.tsx | 120 +++++++++++------- .../PlatformActiveProfileView.tsx | 2 +- ...mProfileCenterController.recharge.test.tsx | 69 ++++++++++ ...rofileCenterController.redemption.test.tsx | 33 +++++ .../usePlatformProfileCenterController.ts | 108 ++++++++++++++-- 15 files changed, 496 insertions(+), 75 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index c4806f4f1..ced080911 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -25,6 +25,7 @@ export function useAccountWallet(currentUserId: string) { setWalletOwner, captureWalletBalanceSnapshot, applyWalletBalanceSnapshot, + applyLegacyWalletBalanceSnapshot, onWalletBalanceMayHaveChanged, resetWalletBalance, } = useWalletStore(); @@ -154,9 +155,11 @@ export function useAccountWallet(currentUserId: string) { mudPointBalance: nextMudPointBalance, ...content } = center; - void walletBalance; if (nextMudPointBalance && walletSnapshot) { applyWalletBalanceSnapshot(walletSnapshot, nextMudPointBalance); + } else if (walletSnapshot && Number.isFinite(walletBalance)) { + applyLegacyWalletBalanceSnapshot(walletSnapshot, walletBalance); + void onWalletBalanceMayHaveChanged(); } else { void onWalletBalanceMayHaveChanged(); } @@ -333,8 +336,7 @@ export function useAccountWallet(currentUserId: string) { walletLedgerError: walletUiIsVisible ? walletLedgerError : null, rechargeOpen: walletUiIsVisible && rechargeOpen, rechargeModalCenter: - rechargeContent && - visibleWalletBalance !== null + rechargeContent && visibleWalletBalance !== null ? { ...rechargeContent, walletBalance: visibleWalletBalance, diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index c5449723b..b3b42fad8 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -53,6 +53,7 @@ describe('useWalletStore', () => { expect(result.current.mudPointBalanceStatus).toBe('ready'); expect(result.current.mudPointBalanceError).toBe(''); expect(Object.keys(result.current).sort()).toEqual([ + 'applyLegacyWalletBalanceSnapshot', 'applyWalletBalanceSnapshot', 'captureWalletBalanceSnapshot', 'legacyWalletBalance', @@ -266,6 +267,34 @@ describe('useWalletStore', () => { }); }); + test('keeps a successful legacy recharge total when the breakdown refresh fails', async () => { + clientApi.getClientProfileRechargeCenter.mockResolvedValueOnce({ + walletBalance: 90, + mudPointBalance: detailedBalance(90), + }); + const { result } = renderHook(() => useAccountWallet('user-a')); + await act(async () => undefined); + clientApi.getClientProfileRechargeCenter + .mockResolvedValueOnce({ + walletBalance: 37, + membership: null, + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }) + .mockRejectedValueOnce(new Error('明细刷新失败')); + + await act(async () => { + await result.current.loadRechargeCenter(); + await Promise.resolve(); + }); + + expect(result.current.legacyWalletBalance).toBe(37); + expect(result.current.mudPointBalance).toBeNull(); + }); + test('keeps ledger loading independent from the recharge lifecycle', async () => { let resolveLedger!: (value: { entries: [] }) => void; clientApi.getClientProfileRechargeCenter.mockResolvedValue({ diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 98a7bad8a..7762a385b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6480,7 +6480,7 @@ - UI 与 mutation:充值 controller 继续保存商品、订单和支付状态,但充值中心响应必须同步写入共享快照,余额 mutation 应在应用响应后再通知一次合并刷新。充值弹窗的余额和分桶由当前 Store 快照覆盖。生成完成、失败退款、兑换码和邀请奖励等事件只发送余额可能变化通知;账号变化同时清理旧充值中心、账单与支付临时状态。`limitedPoints` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。 - 2026-08-05 审查补充:主站 transport adapter 必须通过既有请求 options 真实透传 `AbortSignal`;页面恢复时相邻的 `visibilitychange / focus` 合并为一次余额通知,并在卸载时清理待执行任务。Store 的 owner 输入统一在边界 trim,活动请求清理同时观察 Promise 成功与失败,不能用无人接收的 `finally` 派生 Promise。 - 2026-08-05 账号隔离补充,2026-08-07 完善 legacy 总额入口:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`;owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。 -- 2026-08-07 审查收口补充:共享 Store 显式保存 owner 隔离的 `legacyWalletBalance`,确保首次生命周期读取旧响应时无需先打开充值弹窗即可展示纯总额;该字段不能生成 `mudPointBalance` 分桶。直接余额快照附带单调 operation sequence,后发操作先落地后拒绝更早快照回滚。刷新错误只向 UI 暴露稳定中文提示,不透传 transport / 后端实现文案。 +- 2026-08-07 审查收口补充:共享 Store 显式保存 owner 隔离的 `legacyWalletBalance`,确保首次生命周期读取旧响应时无需先打开充值弹窗即可展示纯总额;较新的 legacy-only 响应必须原子清除旧 `mudPointBalance`,该字段不能生成分桶。直接余额快照附带单调 operation sequence;充值中心、支付确认和 watch 等异步响应都在请求发起前捕获快照,后发操作先落地后拒绝更早快照回滚。刷新错误只向 UI 暴露稳定中文提示,不透传 transport / 后端实现文案。 - 2026-08-07 lifecycle 所有权补充:主站钱包坚持全应用唯一 lifecycle,由 `AuthGate` 根认证边界绑定 ready user;现役平台壳、图片编辑器和 profile controller 只消费 Store,不重复声明 owner。根边界在账号失效、依赖切换、StrictMode effect replay 和最终卸载 cleanup 时统一 `resetWalletBalance`,中止活动请求并清除 owner、明细和 legacy 总额;禁止在子页面按相同 user ID 各自清理 module-level Store。 - 2026-08-05 生成扣退费时序补充:external generation 入队时尚未扣费,worker 领取为 `running` 后的资产操作才预扣,业务失败则先退款再写任务失败态。主站钱包因此以账号下全局 active external task 为轮询生命周期,每轮成功状态读取都通知共享 Store 合并刷新,终态轮同时覆盖成功结算与失败退款;画布内容刷新仍只限当前项目的 `completed`,不因其它项目或 `failed` 刷新画布。 - 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 18b6cc4a2..3d098cf37 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -9,7 +9,7 @@ - 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。 - 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=`。 - 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。 -- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额读取 owner 匹配的钱包 `mudPointBalance.totalPoints`;兼容响应只有 `walletBalance` 时只将该 legacy 总额传给顶部收起态展示,不写回共享钱包,也不伪造不限时、每日免费或重置分桶。余额区只在真实 `mudPointBalance` 存在时展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。 +- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额读取 owner 匹配的钱包 `mudPointBalance.totalPoints`;兼容响应只有 `walletBalance` 时读取共享钱包中 owner 隔离的 legacy 总额,不伪造不限时、每日免费或重置分桶。认证用户的钱包 owner 尚未绑定或 Store 仍为 `idle / loading` 时显示加载态,不能把未完成初始化渲染成余额不可用。余额区只在真实 `mudPointBalance` 存在时展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。 - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮二维平移、`Ctrl / Cmd + 滚轮` 缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 9dbee647f..e16195102 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -59,14 +59,14 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 ## 账户与充值 -1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`;只有充值中心响应缺少 `mudPointBalance` 时,充值弹窗、空账单、个人中心统计卡与图片画板顶部等只展示总额的入口可以在 owner 匹配后使用同一响应的 legacy `walletBalance` 作为展示兜底,不得写回共享钱包状态,也不得据此伪造分桶明细。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。普通充值中心 GET 必须在发起时捕获钱包 owner 生命周期与 invalidation 版本;回包只能结算不晚于该版本的刷新,过期快照不得覆盖余额或中止更新的终态刷新。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;任务列表首次 bootstrap 的 active / terminal 任一读取瞬时失败时必须有界退避重试,成功取得全局 active ID 后再交给常规轮询,不能把首次空状态固化为停止钱包通知。公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 +1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`;只有充值中心响应缺少 `mudPointBalance` 时,Store 才可按 owner 保存同一响应的 legacy `walletBalance` 作为纯总额兜底。较新的 legacy-only 响应必须原子清除旧 `mudPointBalance`,充值弹窗、空账单、个人中心统计卡与图片画板顶部可读取该总额,但不得据此伪造分桶明细。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。普通充值中心 GET 与会应用余额的异步操作必须在发起时捕获钱包 owner 生命周期、invalidation 版本和 operation sequence;回包只能结算不晚于该版本的刷新,过期快照不得覆盖余额或中止更新的终态刷新。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。external generation 在 worker 领取后才预扣泥点,因此主站必须在任一项目的 active task 轮询期间持续推动钱包合并刷新,并在 `completed / failed` 任一终态再刷新以覆盖成功结算或失败退款;任务列表首次 bootstrap 的 active / terminal 任一读取瞬时失败时必须有界退避重试,任一分支成功结果都应立即保留,成功取得全局 active ID 后再交给常规轮询,不能因 terminal 分支失败而清空 active ID、停止钱包通知。公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 2. 账户充值弹窗标题统一为“购买更多泥点”,当前版本只展示泥点商品,不展示会员页签、会员商品、购买会员或升级会员入口。底层会员数据与周期刷新能力继续保留用于存量兼容和结算,会员周期限时泥点不在当前版本前台展示。 3. 泥点默认商品固定为四档:`60 泥点 / ¥6`、`180 + 90 泥点 / ¥18`、`300 + 150 泥点 / ¥30`、`680 + 340 泥点 / ¥68`。`60` 档不加赠,后三档首次购买各加赠基础泥点的 `50%`;实际展示、下单校验和支付确认仍以后端返回的充值商品配置为准。 4. 首充加赠资格按泥点商品档位独立计算。用户买过 `points_180` 后,只影响 `points_180` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。 5. 前端不得用 `hasPointsRecharged` 统一隐藏所有泥点档位首充权益;该字段只表示账号是否发生过任一泥点充值。 6. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5` 或 `wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝。 7. 小程序 WebView 充值使用 `wechat_mp_virtual` 调起小程序虚拟支付;微信内浏览器使用 `wechat_jsapi` 调起微信支付 JSAPI;普通 Web 使用 `wechat_native` 二维码支付,避免因移动 UA、触控能力或窄屏误入 `wechat_h5`。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。一次充值从下单、宿主 / JSAPI / H5 / Native 调起到查单确认与 watch 必须始终携带同一个 `ownerUserId + account lifecycle revision`;pending / confirming order、二维码、提交状态、错误结果、成功回调以及清 token、重新登录等认证副作用在每次写入前都必须校验该令牌,账号切换或卸载后旧链路不得再影响新账号。 -8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。 +8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。前端下单入口还必须使用同步 operation token 防止同一 React 提交周期内重复创建订单;账号切换时,充值、账单、邀请码中心、邀请码输入、弹窗和在途读取结果都必须按账号生命周期整体失效。 9. 后台“充值商品”页继续维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。会员商品配置保留不表示当前版本开放公开购买或升级入口。 ## 唯一后端路线 diff --git a/packages/shared/src/stores/createProfileWalletStore.test.ts b/packages/shared/src/stores/createProfileWalletStore.test.ts index bb55c76f4..c216c73dd 100644 --- a/packages/shared/src/stores/createProfileWalletStore.test.ts +++ b/packages/shared/src/stores/createProfileWalletStore.test.ts @@ -272,6 +272,51 @@ describe('createProfileWalletStore', () => { }); }); + 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'); diff --git a/packages/shared/src/stores/createProfileWalletStore.ts b/packages/shared/src/stores/createProfileWalletStore.ts index 9d834ff70..4cace6e85 100644 --- a/packages/shared/src/stores/createProfileWalletStore.ts +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -34,6 +34,10 @@ export type ProfileWalletStore = { snapshot: ProfileWalletBalanceSnapshot, balance: ProfileMudPointBalance, ) => boolean; + applyLegacyWalletBalanceSnapshot: ( + snapshot: ProfileWalletBalanceSnapshot, + balance: number, + ) => boolean; onWalletBalanceMayHaveChanged: () => Promise; resetWalletBalance: () => void; }; @@ -123,6 +127,32 @@ export function createProfileWalletStore( }); 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) { @@ -152,7 +182,10 @@ export function createProfileWalletStore( } if (!center.mudPointBalance) { if (Number.isFinite(center.walletBalance)) { - set({ legacyWalletBalance: center.walletBalance }); + set({ + mudPointBalance: null, + legacyWalletBalance: center.walletBalance, + }); } throw new Error('充值中心响应缺少泥点余额'); } diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index cc5fc03a0..1d4eef945 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -673,6 +673,12 @@ describe('ImageCanvasEditorView', () => { it('shows the owner-matched legacy wallet total without inventing breakdown rows', async () => { const user = userEvent.setup(); usePlatformWalletStore.getState().setWalletOwner('user-1'); + const walletSnapshot = usePlatformWalletStore + .getState() + .captureWalletBalanceSnapshot('user-1'); + usePlatformWalletStore + .getState() + .applyLegacyWalletBalanceSnapshot(walletSnapshot!, 37); getPlatformProfileRechargeCenterMock.mockResolvedValue({ walletBalance: 37, }); @@ -692,7 +698,7 @@ describe('ImageCanvasEditorView', () => { canAccessProtectedData: true, })} > - + , ); @@ -710,6 +716,34 @@ describe('ImageCanvasEditorView', () => { expect(within(details).queryByText('每日免费泥点')).toBeNull(); }); + it('keeps the wallet entry loading while the authenticated owner is not bound yet', async () => { + render( + + + , + ); + + expect( + (await screen.findByRole('button', { name: '泥点 --' })).getAttribute( + 'aria-busy', + ), + ).toBe('true'); + }); + it('opens the shared wallet breakdown and ledger from the canvas topbar', async () => { const user = userEvent.setup(); usePlatformWalletStore.getState().setWalletOwner('user-1'); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 87db5b39c..cef3c8614 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -331,6 +331,9 @@ export function ImageCanvasEditorView({ const storedMudPointBalance = usePlatformWalletStore( (state) => state.mudPointBalance, ); + const storedLegacyWalletBalance = usePlatformWalletStore( + (state) => state.legacyWalletBalance, + ); const storedMudPointBalanceStatus = usePlatformWalletStore( (state) => state.mudPointBalanceStatus, ); @@ -348,15 +351,19 @@ export function ImageCanvasEditorView({ const mudPointBalance = walletOwnerMatchesCurrentUser ? storedMudPointBalance : null; - const mudPointBalanceStatus = walletOwnerMatchesCurrentUser - ? storedMudPointBalanceStatus - : 'idle'; const mudPointBalanceError = walletOwnerMatchesCurrentUser ? storedMudPointBalanceError : ''; const walletBalance = mudPointBalance?.totalPoints ?? - (walletOwnerMatchesCurrentUser ? legacyWalletBalance : null); + (walletOwnerMatchesCurrentUser + ? (storedLegacyWalletBalance ?? legacyWalletBalance) + : null); + const isWalletBalanceLoading = + Boolean(currentWalletOwnerUserId) && + (!walletOwnerMatchesCurrentUser || + storedMudPointBalanceStatus === 'idle' || + storedMudPointBalanceStatus === 'loading'); const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const assetListRef = useRef(null); @@ -2455,7 +2462,7 @@ export function ImageCanvasEditorView({ layers, walletBalance, walletBreakdown: mudPointBalance, - isWalletBalanceLoading: mudPointBalanceStatus === 'loading', + isWalletBalanceLoading, isWalletDetailsLoading: isLoadingRechargeCenter, walletDetailsError: rechargeError || mudPointBalanceError || null, currentUser: authUi?.user, diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index d4623874d..82747f3ec 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -689,6 +689,63 @@ describe('ImageCanvasTaskSidebarView', () => { } }); + it('keeps a successful active task and wallet polling when only completed tasks fail', async () => { + vi.useFakeTimers(); + try { + const onExternalTaskWalletMayHaveChanged = vi.fn(); + const runningTask = createExternalTask({ + jobId: 'active-while-completed-fails', + requestLabel: '仍在生成的任务', + status: 'running', + }); + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => { + if (options.statuses?.includes('running')) { + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 1, + unacknowledgedTerminalCount: 0, + updatedAtMicros: 1, + }, + tasks: [runningTask], + }); + } + return Promise.reject(new Error('completed tasks unavailable')); + }, + ); + + render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText('仍在生成的任务')).toBeTruthy(); + expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + await Promise.resolve(); + }); + expect(screen.getByText('仍在生成的任务')).toBeTruthy(); + expect( + onExternalTaskWalletMayHaveChanged.mock.calls.length, + ).toBeGreaterThan(1); + } finally { + vi.useRealTimers(); + } + }); + it('stops bootstrap retries after the bounded retry schedule is exhausted', async () => { vi.useFakeTimers(); try { @@ -724,9 +781,7 @@ describe('ImageCanvasTaskSidebarView', () => { status: 'running', }); listExternalGenerationTasksMock.mockImplementation( - ( - options: Parameters[0] = {}, - ) => + (options: Parameters[0] = {}) => Promise.resolve({ overview: { pendingCount: 0, diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx index c81b03e62..73ee61d4a 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -361,53 +361,72 @@ export function ImageCanvasTaskSidebarView({ let controller: AbortController | null = null; let retryTimerId: number | null = null; let retryIndex = 0; + let hasLoadedActiveTasks = false; + let hasLoadedCompletedTasks = false; const loadTaskLists = () => { const attemptController = new AbortController(); controller = attemptController; - Promise.all([ - listExternalGenerationTasks({ - limit: ACTIVE_TASK_LIST_LIMIT, - includeAcknowledgedTerminal: false, - statuses: ['running', 'queued'], - signal: attemptController.signal, - }), - listExternalGenerationTasks({ - limit: completedListLimit, - includeAcknowledgedTerminal: true, - statuses: ['completed', 'failed'], - signal: attemptController.signal, - }), - ]) - .then(([activeResponse, completedResponse]) => { - if (disposed || attemptController.signal.aborted) { - return; - } - const activeTasks = activeResponse.tasks.filter(isActiveExternalTask); - const visibleActiveTasks = filterVisibleExternalTasks(activeTasks); - const visibleCompletedTasks = filterVisibleExternalTasks( - completedResponse.tasks, - ); - setWalletActiveExternalTaskIds(activeTasks.map((task) => task.jobId)); - if ( - activeTasks.length > 0 || - completedResponse.tasks.some( - (task) => - isTerminalExternalTask(task) && - !task.notificationAcknowledgedAt, - ) - ) { - onExternalTaskWalletMayHaveChanged?.(); - } - notifyCompletedExternalTasks(visibleCompletedTasks, { - unacknowledgedOnly: true, - }); - setExternalTasks( - trimStoredExternalTasks( - mergeExternalTasks(visibleActiveTasks, visibleCompletedTasks), - completedListLimit, + const activeTasksRequest = listExternalGenerationTasks({ + limit: ACTIVE_TASK_LIST_LIMIT, + includeAcknowledgedTerminal: false, + statuses: ['running', 'queued'], + signal: attemptController.signal, + }).then((activeResponse) => { + if (disposed || attemptController.signal.aborted) { + return; + } + hasLoadedActiveTasks = true; + const activeTasks = activeResponse.tasks.filter(isActiveExternalTask); + const visibleActiveTasks = filterVisibleExternalTasks(activeTasks); + setWalletActiveExternalTaskIds(activeTasks.map((task) => task.jobId)); + if (activeTasks.length > 0) { + onExternalTaskWalletMayHaveChanged?.(); + } + setExternalTasks((currentTasks) => + trimStoredExternalTasks( + mergeExternalTasks( + visibleActiveTasks, + currentTasks.filter(isTerminalExternalTask), ), - ); - }) + completedListLimit, + ), + ); + }); + const completedTasksRequest = listExternalGenerationTasks({ + limit: completedListLimit, + includeAcknowledgedTerminal: true, + statuses: ['completed', 'failed'], + signal: attemptController.signal, + }).then((completedResponse) => { + if (disposed || attemptController.signal.aborted) { + return; + } + hasLoadedCompletedTasks = true; + const visibleCompletedTasks = filterVisibleExternalTasks( + completedResponse.tasks, + ); + if ( + completedResponse.tasks.some( + (task) => + isTerminalExternalTask(task) && !task.notificationAcknowledgedAt, + ) + ) { + onExternalTaskWalletMayHaveChanged?.(); + } + notifyCompletedExternalTasks(visibleCompletedTasks, { + unacknowledgedOnly: true, + }); + setExternalTasks((currentTasks) => + trimStoredExternalTasks( + mergeExternalTasks( + currentTasks.filter(isActiveExternalTask), + visibleCompletedTasks, + ), + completedListLimit, + ), + ); + }); + Promise.all([activeTasksRequest, completedTasksRequest]) .catch(() => { attemptController.abort(); if (controller === attemptController) { @@ -417,8 +436,19 @@ export function ImageCanvasTaskSidebarView({ return; } if (retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length) { - setExternalTasks([]); - setWalletActiveExternalTaskIds([]); + setExternalTasks((currentTasks) => + trimStoredExternalTasks( + currentTasks.filter( + (task) => + (hasLoadedActiveTasks && isActiveExternalTask(task)) || + (hasLoadedCompletedTasks && isTerminalExternalTask(task)), + ), + completedListLimit, + ), + ); + if (!hasLoadedActiveTasks) { + setWalletActiveExternalTaskIds([]); + } return; } const retryDelayMs = TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS[retryIndex]; diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx index 24d6c3c48..8c5c39e74 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.tsx @@ -11,8 +11,8 @@ import { } from 'lucide-react'; import { useCallback, useRef, useState } from 'react'; -import type { AuthUser } from '@/packages/shared/src'; import type { + AuthUser, ProfileDashboardSummary, ProfileMudPointBalance, } from '@/packages/shared/src'; diff --git a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx index ab759f3a3..b846c6b67 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx @@ -96,6 +96,75 @@ describe('usePlatformProfileCenterController recharge fallback', () => { }); }); + test('synchronously rejects a duplicate recharge submission before React commits state', async () => { + const pendingOrder = deferred>(); + profileClientMocks.createPlatformProfileRechargeOrder.mockReturnValue( + pendingOrder.promise, + ); + usePlatformWalletStore.getState().setWalletOwner('user-a'); + const { result } = renderController(userA); + + act(() => { + result.current.buyRechargeProduct(rechargeProduct); + result.current.buyRechargeProduct(rechargeProduct); + }); + + expect( + profileClientMocks.createPlatformProfileRechargeOrder, + ).toHaveBeenCalledTimes(1); + await act(async () => { + pendingOrder.resolve(nativeRechargeResponse()); + await pendingOrder.promise; + }); + }); + + test('does not let a late payment confirmation overwrite a newer wallet operation', async () => { + const pendingConfirmation = deferred<{ + order: ReturnType['order']; + center: ReturnType['center']; + }>(); + profileClientMocks.createPlatformProfileRechargeOrder.mockResolvedValue( + nativeRechargeResponse(80), + ); + profileClientMocks.confirmWechatPlatformProfileRechargeOrder.mockReturnValue( + pendingConfirmation.promise, + ); + profileClientMocks.getPlatformProfileRechargeCenter.mockRejectedValue( + new Error('refresh unavailable'), + ); + usePlatformWalletStore.getState().setWalletOwner('user-a'); + const { result } = renderController(userA); + act(() => result.current.buyRechargeProduct(rechargeProduct)); + await waitFor(() => { + expect(result.current.nativeWechatPayment?.orderId).toBe('order-a'); + }); + act(() => result.current.confirmNativeWechatPayment()); + + act(() => { + const newerSnapshot = usePlatformWalletStore + .getState() + .captureWalletBalanceSnapshot('user-a'); + usePlatformWalletStore + .getState() + .applyWalletBalanceSnapshot(newerSnapshot!, mudPointBalance(150)); + }); + await act(async () => { + pendingConfirmation.resolve({ + order: { + ...nativeRechargeResponse().order, + status: 'paid', + }, + center: nativeRechargeResponse(100).center, + }); + await pendingConfirmation.promise; + await Promise.resolve(); + }); + + expect(usePlatformWalletStore.getState().mudPointBalance?.totalPoints).toBe( + 150, + ); + }); + test('ignores an old owner order response after the account changes', async () => { const pendingOrder = deferred>(); profileClientMocks.createPlatformProfileRechargeOrder diff --git a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx index 0ac05b86f..8109a67bf 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx @@ -83,4 +83,37 @@ describe('usePlatformProfileCenterController redemption lifecycle', () => { expect(result.current.isSubmittingReferralRedeem).toBe(false); expect(onRechargeSuccess).not.toHaveBeenCalled(); }); + + test('clears referral UI and ignores a late referral-center read after account change', async () => { + let resolveReferralCenter!: (value: never) => void; + profileClientMocks.getPlatformProfileReferralInviteCenter.mockImplementation( + () => + new Promise((resolve) => { + resolveReferralCenter = resolve; + }), + ); + const { result, rerender } = renderController(userA); + act(() => { + result.current.openProfilePopupPanel('redeem'); + result.current.setReferralRedeemCode('USER-A-CODE'); + result.current.loadReferralCenter(); + }); + expect(result.current.profilePopupPanel).toBe('redeem'); + expect(result.current.isLoadingReferral).toBe(true); + + rerender({ user: userB }); + + expect(result.current.profilePopupPanel).toBeNull(); + expect(result.current.referralRedeemCode).toBe(''); + expect(result.current.referralCenter).toBeNull(); + expect(result.current.isLoadingReferral).toBe(false); + await act(async () => { + resolveReferralCenter({ + inviteCode: 'USER-A', + inviteLinkPath: '/?inviteCode=USER-A', + } as never); + }); + expect(result.current.referralCenter).toBeNull(); + expect(result.current.isLoadingReferral).toBe(false); + }); }); diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 4254a5203..4a6206cc4 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -424,9 +424,11 @@ export function usePlatformProfileCenterController({ useRef(null); const confirmingWechatRechargeOrderRef = useRef(null); + const rechargeSubmissionRef = useRef(null); const rechargeCenterReadRevisionRef = useRef(0); const accountLifecycleRevisionRef = useRef(0); const walletLedgerReadRevisionRef = useRef(0); + const referralCenterReadRevisionRef = useRef(0); const currentUserId = currentUser?.id ?? ''; const currentUserIdRef = useRef(currentUserId); currentUserIdRef.current = currentUserId; @@ -523,6 +525,7 @@ export function usePlatformProfileCenterController({ rechargeCenterReadRevisionRef.current += 1; accountLifecycleRevisionRef.current += 1; walletLedgerReadRevisionRef.current += 1; + referralCenterReadRevisionRef.current += 1; const pendingOrder = pendingWechatRechargeOrderRef.current; const confirmingOrder = confirmingWechatRechargeOrderRef.current; abortRechargeOrders(pendingOrder, confirmingOrder); @@ -534,6 +537,7 @@ export function usePlatformProfileCenterController({ } pendingWechatRechargeOrderRef.current = null; confirmingWechatRechargeOrderRef.current = null; + rechargeSubmissionRef.current = null; setRechargeCenter(null); setIsLoadingRechargeCenter(false); setRechargeError(null); @@ -550,7 +554,14 @@ export function usePlatformProfileCenterController({ setIsSubmittingRewardCode(false); setRewardCodeError(null); setRewardCodeSuccess(null); + setProfilePopupPanel(null); + setReferralCenter(null); + setIsLoadingReferral(false); + setIsReferralCenterInitialized(false); + setReferralRedeemCode(''); setIsSubmittingReferralRedeem(false); + setReferralError(null); + setReferralSuccess(null); }, [abortRechargeOrders, currentUserId]); const rechargeCenterReadAbortControllerRef = useRef( null, @@ -560,6 +571,7 @@ export function usePlatformProfileCenterController({ return () => { rechargeCenterReadRevisionRef.current += 1; accountLifecycleRevisionRef.current += 1; + referralCenterReadRevisionRef.current += 1; abortRechargeOrders( pendingWechatRechargeOrderRef.current, confirmingWechatRechargeOrderRef.current, @@ -641,8 +653,8 @@ export function usePlatformProfileCenterController({ ( center: ProfileRechargeCenterResponse, account: AccountLifecycle, - refreshWallet = false, - walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId), + refreshWallet: boolean, + walletSnapshot: ReturnType, ) => { if ( !isAccountLifecycleCurrent(account) || @@ -666,7 +678,6 @@ export function usePlatformProfileCenterController({ }, [ applyWalletBalanceSnapshot, - captureWalletBalanceSnapshot, isAccountLifecycleCurrent, onWalletBalanceMayHaveChanged, ], @@ -782,6 +793,7 @@ export function usePlatformProfileCenterController({ setWechatRechargeOrderConfirmationState({ orderId }); setSubmittingRechargeProductId(null); setRechargePaymentResult(null); + const walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId); void confirmWechatRechargeOrderUntilSettled( orderId, createRechargeConfirmationOptions(order), @@ -798,7 +810,9 @@ export function usePlatformProfileCenterController({ } const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; - if (!applyRechargeCenter(response.center, account, true)) { + if ( + !applyRechargeCenter(response.center, account, true, walletSnapshot) + ) { return; } if ( @@ -860,6 +874,7 @@ export function usePlatformProfileCenterController({ }, [ applyRechargeCenter, captureAccountLifecycle, + captureWalletBalanceSnapshot, createRechargeConfirmationOptions, createRechargeOrderLifecycle, isAccountLifecycleCurrent, @@ -890,6 +905,9 @@ export function usePlatformProfileCenterController({ confirmingWechatRechargeOrderRef.current = order; setWechatRechargeOrderConfirmationState({ orderId: order.orderId }); setRechargePaymentResult(null); + const walletSnapshot = captureWalletBalanceSnapshot( + order.account.ownerUserId, + ); void confirmWechatRechargeOrderUntilSettled( order.orderId, createRechargeConfirmationOptions(order), @@ -903,7 +921,14 @@ export function usePlatformProfileCenterController({ } const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; - if (!applyRechargeCenter(response.center, order.account, true)) { + if ( + !applyRechargeCenter( + response.center, + order.account, + true, + walletSnapshot, + ) + ) { return; } pendingWechatRechargeOrderRef.current = null; @@ -934,6 +959,7 @@ export function usePlatformProfileCenterController({ return true; }, [ applyRechargeCenter, + captureWalletBalanceSnapshot, createRechargeConfirmationOptions, isRechargeOrderCurrent, isSameRechargeOrder, @@ -989,12 +1015,15 @@ export function usePlatformProfileCenterController({ (product: ProfileRechargeProduct) => { const account = captureAccountLifecycle(); if ( + rechargeSubmissionRef.current || submittingRechargeProductId || !account.ownerUserId || !isAccountLifecycleCurrent(account) ) { return; } + const submissionToken = Symbol('profile-recharge-submission'); + rechargeSubmissionRef.current = submissionToken; const walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId); let createdOrder: WechatRechargeOrderLifecycle | null = null; @@ -1076,6 +1105,9 @@ export function usePlatformProfileCenterController({ title: '支付处理中', message: '正在查询微信支付到账状态。', }); + const confirmationWalletSnapshot = captureWalletBalanceSnapshot( + account.ownerUserId, + ); void confirmWechatRechargeOrderUntilSettled( response.order.orderId, createRechargeConfirmationOptions(order), @@ -1089,7 +1121,12 @@ export function usePlatformProfileCenterController({ ); const isPaid = result.kind === 'success'; if ( - !applyRechargeCenter(confirmResponse.center, account, true) + !applyRechargeCenter( + confirmResponse.center, + account, + true, + confirmationWalletSnapshot, + ) ) { return; } @@ -1247,6 +1284,11 @@ export function usePlatformProfileCenterController({ } setRechargeError(error instanceof Error ? error.message : '充值失败'); setSubmittingRechargeProductId(null); + }) + .finally(() => { + if (rechargeSubmissionRef.current === submissionToken) { + rechargeSubmissionRef.current = null; + } }); }, [ @@ -1281,6 +1323,9 @@ export function usePlatformProfileCenterController({ ? { ...current, isConfirming: true, confirmMessage: undefined } : current, ); + const walletSnapshot = captureWalletBalanceSnapshot( + order.account.ownerUserId, + ); void confirmWechatRechargeOrderQuickly( nativeWechatPayment.orderId, createRechargeConfirmationOptions(order), @@ -1291,7 +1336,14 @@ export function usePlatformProfileCenterController({ } const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; - if (!applyRechargeCenter(response.center, order.account, true)) { + if ( + !applyRechargeCenter( + response.center, + order.account, + true, + walletSnapshot, + ) + ) { return; } if (result.kind !== 'pending') { @@ -1339,6 +1391,7 @@ export function usePlatformProfileCenterController({ }); }, [ applyRechargeCenter, + captureWalletBalanceSnapshot, createRechargeConfirmationOptions, isRechargeOrderCurrent, isSameRechargeOrder, @@ -1386,6 +1439,9 @@ export function usePlatformProfileCenterController({ ) { try { assertWechatRechargeConfirmationActive(confirmationOptions); + const walletSnapshot = captureWalletBalanceSnapshot( + order.account.ownerUserId, + ); const response = await watchWechatPlatformProfileRechargeOrder( orderId, { @@ -1402,7 +1458,14 @@ export function usePlatformProfileCenterController({ } const result = buildRechargePaymentResultForOrder(response.order); - if (!applyRechargeCenter(response.center, order.account, true)) { + if ( + !applyRechargeCenter( + response.center, + order.account, + true, + walletSnapshot, + ) + ) { return; } if (result.kind === 'pending') { @@ -1465,6 +1528,7 @@ export function usePlatformProfileCenterController({ nativeWechatPayment?.expiresAt, nativeWechatPayment?.orderId, applyRechargeCenter, + captureWalletBalanceSnapshot, createRechargeConfirmationOptions, isRechargeOrderCurrent, isSameRechargeOrder, @@ -1538,21 +1602,41 @@ export function usePlatformProfileCenterController({ ]); const loadReferralCenter = useCallback(() => { + const account = captureAccountLifecycle(); + const revision = ++referralCenterReadRevisionRef.current; setIsLoadingReferral(true); setIsReferralCenterInitialized(false); void getPlatformProfileReferralInviteCenter() - .then(setReferralCenter) + .then((center) => { + if ( + revision === referralCenterReadRevisionRef.current && + isAccountLifecycleCurrent(account) + ) { + setReferralCenter(center); + } + }) .catch((error: unknown) => { + if ( + revision !== referralCenterReadRevisionRef.current || + !isAccountLifecycleCurrent(account) + ) { + return; + } setReferralCenter(null); setReferralError( error instanceof Error ? error.message : '读取邀请码失败', ); }) .finally(() => { - setIsReferralCenterInitialized(true); - setIsLoadingReferral(false); + if ( + revision === referralCenterReadRevisionRef.current && + isAccountLifecycleCurrent(account) + ) { + setIsReferralCenterInitialized(true); + setIsLoadingReferral(false); + } }); - }, []); + }, [captureAccountLifecycle, isAccountLifecycleCurrent]); useEffect(() => { if (activeTab !== 'profile' || !isAuthenticated) { -- 2.52.0 From 0910504a468a8cd89ec4eeeee818a915be49a87e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 13:30:04 +0800 Subject: [PATCH 32/36] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E5=9B=9E=E8=B7=B3=E5=85=B3=E8=81=94=E5=BE=85?= =?UTF-8?q?=E5=8A=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 说明小程序支付期间切号风险较低 记录未来跨 WebView 恢复所需的账号订单关联校验 --- .../platform-entry/usePlatformProfileCenterController.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 4a6206cc4..336e6f5ca 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -770,6 +770,9 @@ export function usePlatformProfileCenterController({ return false; } + // TODO:小程序支付回跳通常仍是同一账号,当前风险很低;若以后支持支付期间切号或 + // 跨 WebView 恢复,应持久化并校验 requestId + orderId + ownerUserId 后再采纳无 + // 内存 pending order 的 hash。后端订单 owner 校验继续作为最终安全边界。 const account = pendingOrder?.account ?? captureAccountLifecycle(); if (!account.ownerUserId || !isAccountLifecycleCurrent(account)) { return false; -- 2.52.0 From bc6d3d0ea84770faebeebc992f8f78be87cfc3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 14:10:01 +0800 Subject: [PATCH 33/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=A3=B3=E7=94=9F=E4=BA=A7=E6=BA=90=E7=A0=81=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除个人中心控制器中的 TODO 开发标记 --- .../platform-entry/usePlatformProfileCenterController.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 336e6f5ca..666797531 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -770,9 +770,9 @@ export function usePlatformProfileCenterController({ return false; } - // TODO:小程序支付回跳通常仍是同一账号,当前风险很低;若以后支持支付期间切号或 - // 跨 WebView 恢复,应持久化并校验 requestId + orderId + ownerUserId 后再采纳无 - // 内存 pending order 的 hash。后端订单 owner 校验继续作为最终安全边界。 + // 中文注释:无内存 pending order 的小程序回跳暂按当前账号上下文处理,后端订单 + // owner 校验仍是最终安全边界;跨 WebView 恢复若引入持久关联,必须同时校验 + // requestId、orderId 与 ownerUserId。 const account = pendingOrder?.account ?? captureAccountLifecycle(); if (!account.ownerUserId || !isAccountLifecycleCurrent(account)) { return false; -- 2.52.0 From e95c2d73bff257b0cc3e7245c11b5af770bbcb63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 15:32:27 +0800 Subject: [PATCH 34/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=97=B6=E4=B8=AA=E4=BA=BA=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E5=86=99=E8=AF=B7=E6=B1=82=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为充值下单、邀请码兑换和奖励码兑换绑定账号生命周期中止信号 补充切号、卸载、401 与 503 重试回归测试 按 Prettier 3.3.3 修复分支变更文件格式并更新工程约束 --- .../tests/appSurface/home.suite.ts | 4 +- .../shared-memory/decision-log.md | 3 +- docs/project-memory/shared-memory/pitfalls.md | 1 + ...项目基线】当前产品与工程约束-2026-05-15.md | 2 +- src/components/auth/AuthGate.tsx | 38 +++--- ...mProfileCenterController.recharge.test.tsx | 22 ++++ ...rofileCenterController.redemption.test.tsx | 9 ++ .../usePlatformProfileCenterController.ts | 44 ++++++- src/services/apiClient.test.ts | 110 ++++++++++++++++-- .../platformProfileClient.test.ts | 38 +++++- 10 files changed, 232 insertions(+), 39 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e718dc847..9a6bb3945 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -369,9 +369,7 @@ export function registerClientHomeTests() { source: { kind: 'generated' }, }, ]; - let resolveStaleRefresh!: ( - manifest: typeof staleFirstManifest, - ) => void; + let resolveStaleRefresh!: (manifest: typeof staleFirstManifest) => void; const staleRefresh = new Promise((resolve) => { resolveStaleRefresh = resolve; }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7762a385b..7a44d2302 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6298,6 +6298,7 @@ - 影响范围:`/editor/canvas` 的 `audio-background-music` 面板撤销按钮与其定向测试;不改变单层交换快照语义、canonicalization、提交锁、Suno 契约或后端 Prompt 助手,也不修改状态模型字段,`temporaryPromptSnapshot` 已在公开 dialog 状态中且只在 `completing` / `simplifying` 期间非空。本条不适用于 SFX,V1.0 不改动 SFX 的一键优化与撤销行为。 - 验证方式:按矩阵逐行覆盖初始隐藏、首次与再次 AI 处理期间显示并禁用、成功启用、失败隐藏、手动编辑后仍启用、点击预设隐藏、`submitting` 有无快照的两种表现、解除锁定后恢复,以及连续撤销互换保持启用;并断言处理期间按钮仍在可访问树中且为真实禁用态。 - 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。 + ## 2026-08-03 完美像素对账判据改看 dialog 收口状态,网关合成响应归入未知结果 - 缺陷一(对账把真成功判成失败):对账用「同 ID 的 generation-dialog 是否还在权威快照里」判定成败,而服务端成功回填时**保留**该 dialog 并就地改写——`apply_editor_canvas_generation_items` 置 `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`,该行为另有服务端测试断言 `dialog["generatedLayerId"]` 钉住。所以响应丢失但服务端其实已完成时,判据反向:用户被告知「画布未收到完美像素结果,请确认素材库」,而结果早已在画布上,重做一遍就造出第二份;这条分支还刻意不套用快照,本地也看不到那个新图层。 @@ -6479,7 +6480,7 @@ - 并发与账号边界:同一 owner generation 同时只执行一个余额读取;读取期间的新变化通知在当前请求结束后补读,直到覆盖最后一次通知。切换或退出账号立即清空快照、提升 generation、中止并脱离旧 generation 的 active 请求,新 owner 不得等待旧 transport 收束;不响应 abort 的旧 transport 可以在后台结束,但其结果必须忽略。消费端在 owner 绑定 effect 提交前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,不能让旧余额与新账号身份同屏。充值中心读取或 mutation 响应必须携带请求开始时捕获的 owner,owner 不符时忽略。刷新失败保留已有快照;响应缺少 `mudPointBalance` 时进入错误状态,不用总额反推分桶。 - UI 与 mutation:充值 controller 继续保存商品、订单和支付状态,但充值中心响应必须同步写入共享快照,余额 mutation 应在应用响应后再通知一次合并刷新。充值弹窗的余额和分桶由当前 Store 快照覆盖。生成完成、失败退款、兑换码和邀请奖励等事件只发送余额可能变化通知;账号变化同时清理旧充值中心、账单与支付临时状态。`limitedPoints` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。 - 2026-08-05 审查补充:主站 transport adapter 必须通过既有请求 options 真实透传 `AbortSignal`;页面恢复时相邻的 `visibilitychange / focus` 合并为一次余额通知,并在卸载时清理待执行任务。Store 的 owner 输入统一在边界 trim,活动请求清理同时观察 Promise 成功与失败,不能用无人接收的 `finally` 派生 Promise。 -- 2026-08-05 账号隔离补充,2026-08-07 完善 legacy 总额入口:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`;owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。 +- 2026-08-05 账号隔离补充,2026-08-07 完善 legacy 总额入口:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。充值下单、邀请码兑换和奖励码兑换三条写请求还必须共享账号生命周期 `AbortController`,在切号 effect cleanup 与卸载时中止旧 signal,使 `fetchWithApiAuth` 的 refresh 等待和写请求退避立即结束,禁止旧 POST 重试重新读取新账号 Token。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`;owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。 - 2026-08-07 审查收口补充:共享 Store 显式保存 owner 隔离的 `legacyWalletBalance`,确保首次生命周期读取旧响应时无需先打开充值弹窗即可展示纯总额;较新的 legacy-only 响应必须原子清除旧 `mudPointBalance`,该字段不能生成分桶。直接余额快照附带单调 operation sequence;充值中心、支付确认和 watch 等异步响应都在请求发起前捕获快照,后发操作先落地后拒绝更早快照回滚。刷新错误只向 UI 暴露稳定中文提示,不透传 transport / 后端实现文案。 - 2026-08-07 lifecycle 所有权补充:主站钱包坚持全应用唯一 lifecycle,由 `AuthGate` 根认证边界绑定 ready user;现役平台壳、图片编辑器和 profile controller 只消费 Store,不重复声明 owner。根边界在账号失效、依赖切换、StrictMode effect replay 和最终卸载 cleanup 时统一 `resetWalletBalance`,中止活动请求并清除 owner、明细和 legacy 总额;禁止在子页面按相同 user ID 各自清理 module-level Store。 - 2026-08-05 生成扣退费时序补充:external generation 入队时尚未扣费,worker 领取为 `running` 后的资产操作才预扣,业务失败则先退款再写任务失败态。主站钱包因此以账号下全局 active external task 为轮询生命周期,每轮成功状态读取都通知共享 Store 合并刷新,终态轮同时覆盖成功结算与失败退款;画布内容刷新仍只限当前项目的 `completed`,不因其它项目或 `failed` 刷新画布。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b5159fae8..f5ce1bc21 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4332,6 +4332,7 @@ - 原因:把 dialog / canvas 的完整 UI 所有权同时用于账号级钱包和账号内项目级任务列表,或者任务列表只比较 project ID,没有校验账号。 - 处理:按副作用分层校验。钱包只比较账号;任务列表比较账号加项目;dialog、canvas、asset 和 layer 写回继续比较账号、项目、scope version 与原 dialog。正式请求已接受后,删除 UI 状态不等于取消后端任务。 - 验证:分别覆盖删除 dialog、同账号切项目、账号 A 切到账号 B 且 project ID 保持相同,以及原账号原项目原 dialog 仍有效的正常回写。 + ## GUI owner 锁不能替代逐 boot 的事件接收端登记(2026-08-05) - 现象:GUI 首次启动后 manifest 事件转发正常,但 Runner 被替换为新 boot 后只剩 owner 锁和 endpoint 可用,后台更新不再到达 GUI;或者 attach 响应只确认 owner,客户端却误记当前 boot 已完整登记,后续 ensure 不再重试。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index e16195102..6e2c68477 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -66,7 +66,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 5. 前端不得用 `hasPointsRecharged` 统一隐藏所有泥点档位首充权益;该字段只表示账号是否发生过任一泥点充值。 6. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5` 或 `wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝。 7. 小程序 WebView 充值使用 `wechat_mp_virtual` 调起小程序虚拟支付;微信内浏览器使用 `wechat_jsapi` 调起微信支付 JSAPI;普通 Web 使用 `wechat_native` 二维码支付,避免因移动 UA、触控能力或窄屏误入 `wechat_h5`。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。一次充值从下单、宿主 / JSAPI / H5 / Native 调起到查单确认与 watch 必须始终携带同一个 `ownerUserId + account lifecycle revision`;pending / confirming order、二维码、提交状态、错误结果、成功回调以及清 token、重新登录等认证副作用在每次写入前都必须校验该令牌,账号切换或卸载后旧链路不得再影响新账号。 -8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。前端下单入口还必须使用同步 operation token 防止同一 React 提交周期内重复创建订单;账号切换时,充值、账单、邀请码中心、邀请码输入、弹窗和在途读取结果都必须按账号生命周期整体失效。 +8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。前端下单入口还必须使用同步 operation token 防止同一 React 提交周期内重复创建订单;充值下单、邀请码兑换和奖励码兑换必须绑定同一个账号生命周期 `AbortSignal`,切号或卸载时先中止旧 signal,禁止 POST 的 401、503 或网络重试重新读取新账号 Token。账号切换时,充值、账单、邀请码中心、邀请码输入、弹窗和在途读取结果都必须按账号生命周期整体失效。 9. 后台“充值商品”页继续维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。会员商品配置保留不表示当前版本开放公开购买或升级入口。 ## 唯一后端路线 diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index e9cb49d95..0af9b7027 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -118,10 +118,7 @@ function normalizeAvailableLoginMethods( // 登录面板的核心入口必须稳定展示,login-options 只补充微信等环境相关入口。 return Array.from( - new Set([ - ...REQUIRED_LOGIN_METHODS, - ...normalizedMethods, - ]), + new Set([...REQUIRED_LOGIN_METHODS, ...normalizedMethods]), ); } @@ -193,10 +190,7 @@ export function AuthGate({ children }: AuthGateProps) { } const markAuthStateReloadIfChanged = useCallback( - ( - nextUser: AuthUser | null, - options: { reloadOnChange?: boolean } = {}, - ) => { + (nextUser: AuthUser | null, options: { reloadOnChange?: boolean } = {}) => { const nextHasUser = Boolean(nextUser); const previousHasUser = lastStableAuthPresenceRef.current; if (previousHasUser === null) { @@ -205,23 +199,23 @@ export function AuthGate({ children }: AuthGateProps) { } lastStableAuthPresenceRef.current = nextHasUser; - if ( - previousHasUser !== nextHasUser && - options.reloadOnChange !== false - ) { + if (previousHasUser !== nextHasUser && options.reloadOnChange !== false) { pendingAuthStateReloadRef.current = true; } }, [], ); - const activateReadyUser = useCallback((nextUser: AuthUser) => { - // 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。 - authHydrateVersionRef.current += 1; - markAuthStateReloadIfChanged(nextUser); - setUser(nextUser); - setStatus('ready'); - }, [markAuthStateReloadIfChanged]); + const activateReadyUser = useCallback( + (nextUser: AuthUser) => { + // 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。 + authHydrateVersionRef.current += 1; + markAuthStateReloadIfChanged(nextUser); + setUser(nextUser); + setStatus('ready'); + }, + [markAuthStateReloadIfChanged], + ); const clearLocalAuthenticatedState = useCallback( (options: { reloadOnChange?: boolean } = {}) => { @@ -975,7 +969,11 @@ export function AuthGate({ children }: AuthGateProps) { const registrationInviteCode = pendingInviteCode || readInviteCodeFromLocation(); const response = registrationInviteCode - ? await loginWithPhoneCode(phone, code, registrationInviteCode) + ? await loginWithPhoneCode( + phone, + code, + registrationInviteCode, + ) : await loginWithPhoneCode(phone, code); const autoRedeemedInvite = response.referral?.ok === true; setStoredLastLoginPhone(phone); diff --git a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx index b846c6b67..5b222cc95 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx @@ -174,12 +174,17 @@ describe('usePlatformProfileCenterController recharge fallback', () => { const { result, rerender } = renderController(userA); act(() => result.current.buyRechargeProduct(rechargeProduct)); + const oldAccountSignal = profileClientMocks + .createPlatformProfileRechargeOrder.mock.calls[0]?.[2] + ?.signal as AbortSignal; + expect(oldAccountSignal.aborted).toBe(false); expect(result.current.submittingRechargeProductId).toBe('points-60'); act(() => { rerender({ user: userB }); usePlatformWalletStore.getState().setWalletOwner('user-b'); }); + expect(oldAccountSignal.aborted).toBe(true); act(() => result.current.buyRechargeProduct(rechargeProduct)); await waitFor(() => { expect(result.current.nativeWechatPayment?.orderId).toBe('order-b'); @@ -200,6 +205,23 @@ describe('usePlatformProfileCenterController recharge fallback', () => { }); }); + test('aborts an in-flight recharge write when the controller unmounts', () => { + profileClientMocks.createPlatformProfileRechargeOrder.mockReturnValue( + new Promise(() => undefined), + ); + usePlatformWalletStore.getState().setWalletOwner('user-a'); + const { result, unmount } = renderController(userA); + + act(() => result.current.buyRechargeProduct(rechargeProduct)); + const signal = profileClientMocks.createPlatformProfileRechargeOrder.mock + .calls[0]?.[2]?.signal as AbortSignal; + expect(signal.aborted).toBe(false); + + unmount(); + + expect(signal.aborted).toBe(true); + }); + test('ignores an old owner native confirmation after the account changes', async () => { const pendingConfirmation = deferred<{ order: ReturnType['order']; diff --git a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx index 8109a67bf..dbbb10029 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx @@ -34,8 +34,12 @@ describe('usePlatformProfileCenterController redemption lifecycle', () => { const { result, rerender } = renderController(userA, onRechargeSuccess); act(() => result.current.setRewardCodeInput('reward-code')); act(() => result.current.submitRewardCode()); + const oldAccountSignal = profileClientMocks.redeemPlatformProfileRewardCode + .mock.calls[0]?.[1]?.signal as AbortSignal; + expect(oldAccountSignal.aborted).toBe(false); rerender({ user: userB }); + expect(oldAccountSignal.aborted).toBe(true); await act(async () => { resolveReward({ walletBalance: 100, @@ -67,8 +71,13 @@ describe('usePlatformProfileCenterController redemption lifecycle', () => { const { result, rerender } = renderController(userA, onRechargeSuccess); act(() => result.current.setReferralRedeemCode('invite-code')); act(() => result.current.submitReferralRedeemCode()); + const oldAccountSignal = profileClientMocks + .redeemPlatformProfileReferralInviteCode.mock.calls[0]?.[1] + ?.signal as AbortSignal; + expect(oldAccountSignal.aborted).toBe(false); rerender({ user: userB }); + expect(oldAccountSignal.aborted).toBe(true); await act(async () => { resolveReferral({ center: undefined as never, diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 666797531..03a5f423d 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -61,7 +61,11 @@ type WechatPayResult = { }; type RechargePaymentResultKind = - 'success' | 'pending' | 'cancel' | 'failed' | 'expired'; + | 'success' + | 'pending' + | 'cancel' + | 'failed' + | 'expired'; export type RechargePaymentResult = { kind: RechargePaymentResultKind; @@ -425,6 +429,7 @@ export function usePlatformProfileCenterController({ const confirmingWechatRechargeOrderRef = useRef(null); const rechargeSubmissionRef = useRef(null); + const accountWriteAbortControllerRef = useRef(new AbortController()); const rechargeCenterReadRevisionRef = useRef(0); const accountLifecycleRevisionRef = useRef(0); const walletLedgerReadRevisionRef = useRef(0); @@ -522,6 +527,9 @@ export function usePlatformProfileCenterController({ : null; useEffect(() => { + accountWriteAbortControllerRef.current.abort(); + const accountWriteAbortController = new AbortController(); + accountWriteAbortControllerRef.current = accountWriteAbortController; rechargeCenterReadRevisionRef.current += 1; accountLifecycleRevisionRef.current += 1; walletLedgerReadRevisionRef.current += 1; @@ -562,6 +570,9 @@ export function usePlatformProfileCenterController({ setIsSubmittingReferralRedeem(false); setReferralError(null); setReferralSuccess(null); + return () => { + accountWriteAbortController.abort(); + }; }, [abortRechargeOrders, currentUserId]); const rechargeCenterReadAbortControllerRef = useRef( null, @@ -1027,6 +1038,11 @@ export function usePlatformProfileCenterController({ } const submissionToken = Symbol('profile-recharge-submission'); rechargeSubmissionRef.current = submissionToken; + const requestSignal = accountWriteAbortControllerRef.current.signal; + if (requestSignal.aborted) { + rechargeSubmissionRef.current = null; + return; + } const walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId); let createdOrder: WechatRechargeOrderLifecycle | null = null; @@ -1039,7 +1055,13 @@ export function usePlatformProfileCenterController({ setRechargePaymentResult(null); setWechatRechargeOrderConfirmationState(null); setNativeWechatPayment(null); - void createPlatformProfileRechargeOrder(product.productId, paymentChannel) + void createPlatformProfileRechargeOrder( + product.productId, + paymentChannel, + { + signal: requestSignal, + }, + ) .then(async (response) => { if (!isAccountLifecycleCurrent(account)) { return; @@ -1709,7 +1731,14 @@ export function usePlatformProfileCenterController({ setReferralSuccess(null); const snapshotOwnerUserId = currentUserId; const accountRevision = accountLifecycleRevisionRef.current; - void redeemPlatformProfileReferralInviteCode(inviteCode) + const requestSignal = accountWriteAbortControllerRef.current.signal; + if (requestSignal.aborted) { + setIsSubmittingReferralRedeem(false); + return; + } + void redeemPlatformProfileReferralInviteCode(inviteCode, { + signal: requestSignal, + }) .then((response) => { if ( accountRevision !== accountLifecycleRevisionRef.current || @@ -1760,7 +1789,14 @@ export function usePlatformProfileCenterController({ setRewardCodeSuccess(null); const snapshotOwnerUserId = currentUserId; const accountRevision = accountLifecycleRevisionRef.current; - void redeemPlatformProfileRewardCode(rewardCodeInput) + const requestSignal = accountWriteAbortControllerRef.current.signal; + if (requestSignal.aborted) { + setIsSubmittingRewardCode(false); + return; + } + void redeemPlatformProfileRewardCode(rewardCodeInput, { + signal: requestSignal, + }) .then((response: RedeemProfileRewardCodeResponse) => { if ( accountRevision !== accountLifecycleRevisionRef.current || diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index 8bd1ba54a..2ef89976e 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -662,6 +662,91 @@ describe('apiClient', () => { ); }); + it('does not retry an unsafe write with a new token after its account signal aborts', async () => { + setStoredAccessToken('account-a-token', { emit: false }); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 503 })) + .mockResolvedValueOnce(createResponseMock({ status: 200 })); + const abortController = new AbortController(); + + const request = requestJson( + '/api/profile/redeem-codes/redeem', + { + method: 'POST', + body: JSON.stringify({ code: 'REWARD01' }), + signal: abortController.signal, + }, + '兑换失败', + { + retry: { + maxRetries: 1, + baseDelayMs: 1000, + maxDelayMs: 1000, + retryUnsafeMethods: true, + }, + }, + ); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + setStoredAccessToken('account-b-token', { emit: false }); + abortController.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect( + (fetchMock.mock.calls[0]?.[1]?.headers as Record) + .Authorization, + ).toBe('Bearer account-a-token'); + }); + + it('does not replay a 401 unsafe write after its account signal aborts during refresh', async () => { + setStoredAccessToken('account-a-token', { emit: false }); + const refreshResponse = + createDeferred>(); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockImplementationOnce(() => refreshResponse.promise) + .mockResolvedValueOnce(createResponseMock({ status: 200 })); + const abortController = new AbortController(); + + const request = requestJson( + '/api/profile/redeem-codes/redeem', + { + method: 'POST', + body: JSON.stringify({ code: 'REWARD01' }), + signal: abortController.signal, + }, + '兑换失败', + ); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + setStoredAccessToken('account-b-token', { emit: false }); + abortController.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + refreshResponse.resolve( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { token: 'late-refresh-token' }, + error: null, + meta: { apiVersion: '2026-06-16' }, + }), + }), + ); + await refreshResponse.promise; + await Promise.resolve(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/api/profile/redeem-codes/redeem', + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/auth/refresh'); + }); + it('aborts requests when timeoutMs is reached', async () => { setStoredAccessToken('timeout-token', { emit: false }); fetchMock.mockImplementation( @@ -700,9 +785,8 @@ describe('apiClient', () => { it.each(['missing-token', 'unauthorized'] as const)( 'bounds the %s refresh wait with an absolute request deadline', async (refreshMode) => { - const refreshResponse = createDeferred< - ReturnType - >(); + const refreshResponse = + createDeferred>(); if (refreshMode === 'unauthorized') { setStoredAccessToken('expired-token', { emit: false }); fetchMock @@ -853,9 +937,13 @@ describe('apiClient', () => { ); await expect( - requestJson('/api/runtime/puzzle/agent/sessions/test/actions', { - method: 'POST', - }, '执行拼图操作失败。'), + requestJson( + '/api/runtime/puzzle/agent/sessions/test/actions', + { + method: 'POST', + }, + '执行拼图操作失败。', + ), ).rejects.toMatchObject({ message: '拼图图片生成失败:请求参数不合法', status: 400, @@ -895,9 +983,13 @@ describe('apiClient', () => { ); await expect( - requestJson('/api/runtime/puzzle/agent/sessions/test/actions', { - method: 'POST', - }, '执行拼图操作失败。'), + requestJson( + '/api/runtime/puzzle/agent/sessions/test/actions', + { + method: 'POST', + }, + '执行拼图操作失败。', + ), ).rejects.toMatchObject({ message: '无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置', diff --git a/src/services/platform-entry/platformProfileClient.test.ts b/src/services/platform-entry/platformProfileClient.test.ts index a4f2e24e7..f36b4a5fc 100644 --- a/src/services/platform-entry/platformProfileClient.test.ts +++ b/src/services/platform-entry/platformProfileClient.test.ts @@ -7,7 +7,12 @@ const apiClientMocks = vi.hoisted(() => ({ vi.mock('../apiClient', () => apiClientMocks); -import { getPlatformProfileRechargeCenter } from './platformProfileClient'; +import { + createPlatformProfileRechargeOrder, + getPlatformProfileRechargeCenter, + redeemPlatformProfileReferralInviteCode, + redeemPlatformProfileRewardCode, +} from './platformProfileClient'; describe('platformProfileClient', () => { beforeEach(() => { @@ -34,4 +39,35 @@ describe('platformProfileClient', () => { }), ); }); + + test.each([ + [ + '充值下单', + (signal: AbortSignal) => + createPlatformProfileRechargeOrder('points-60', 'wechat_native', { + signal, + }), + ], + [ + '邀请码兑换', + (signal: AbortSignal) => + redeemPlatformProfileReferralInviteCode('INVITE01', { signal }), + ], + [ + '奖励码兑换', + (signal: AbortSignal) => + redeemPlatformProfileRewardCode('REWARD01', { signal }), + ], + ])('%s write forwards its account abort signal', async (_label, request) => { + const abortController = new AbortController(); + + await request(abortController.signal); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + expect.stringMatching(/^\/api\/profile\//u), + expect.objectContaining({ signal: abortController.signal }), + expect.any(String), + expect.objectContaining({ retry: expect.any(Object) }), + ); + }); }); -- 2.52.0 From 0f49069b62a568bd5b64df0295d38aad7b858643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 19:09:18 +0800 Subject: [PATCH 35/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E8=B4=A6=E5=8F=B7=E4=BB=A3=E9=99=85=E9=9A=94?= =?UTF-8?q?=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按认证代际与起始令牌隔离共享 refresh 并使用 CAS 发布新令牌 防止旧 refresh 失败清理新账号令牌 补充晚到回包与新账号独立 refresh 回归测试 同步项目基线与共享项目记忆 --- .../shared-memory/decision-log.md | 1 + docs/project-memory/shared-memory/pitfalls.md | 7 + ...项目基线】当前产品与工程约束-2026-05-15.md | 2 +- src/services/apiClient.test.ts | 81 +++++++++++- src/services/apiClient.ts | 124 +++++++++++++++--- 5 files changed, 195 insertions(+), 20 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7a44d2302..a1439eaa5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6483,6 +6483,7 @@ - 2026-08-05 账号隔离补充,2026-08-07 完善 legacy 总额入口:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。充值下单、邀请码兑换和奖励码兑换三条写请求还必须共享账号生命周期 `AbortController`,在切号 effect cleanup 与卸载时中止旧 signal,使 `fetchWithApiAuth` 的 refresh 等待和写请求退避立即结束,禁止旧 POST 重试重新读取新账号 Token。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`;owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。 - 2026-08-07 审查收口补充:共享 Store 显式保存 owner 隔离的 `legacyWalletBalance`,确保首次生命周期读取旧响应时无需先打开充值弹窗即可展示纯总额;较新的 legacy-only 响应必须原子清除旧 `mudPointBalance`,该字段不能生成分桶。直接余额快照附带单调 operation sequence;充值中心、支付确认和 watch 等异步响应都在请求发起前捕获快照,后发操作先落地后拒绝更早快照回滚。刷新错误只向 UI 暴露稳定中文提示,不透传 transport / 后端实现文案。 - 2026-08-07 lifecycle 所有权补充:主站钱包坚持全应用唯一 lifecycle,由 `AuthGate` 根认证边界绑定 ready user;现役平台壳、图片编辑器和 profile controller 只消费 Store,不重复声明 owner。根边界在账号失效、依赖切换、StrictMode effect replay 和最终卸载 cleanup 时统一 `resetWalletBalance`,中止活动请求并清除 owner、明细和 legacy 总额;禁止在子页面按相同 user ID 各自清理 module-level Store。 +- 2026-08-07 refresh 发布隔离补充:`apiClient` 把公开 token 设置与清理视为认证代际变更,共享 `/api/auth/refresh` 只在“代际 + 发起时 token”快照相同时复用。refresh 成功只能以同一快照 CAS 发布新 token,旧账号晚到成功必须拒绝;旧 refresh 的 401/403 也只能在原快照仍当前时清 token。新账号进入新代际后立即发起独立 refresh,不等待也不加入旧账号 Promise。 - 2026-08-05 生成扣退费时序补充:external generation 入队时尚未扣费,worker 领取为 `running` 后的资产操作才预扣,业务失败则先退款再写任务失败态。主站钱包因此以账号下全局 active external task 为轮询生命周期,每轮成功状态读取都通知共享 Store 合并刷新,终态轮同时覆盖成功结算与失败退款;画布内容刷新仍只限当前项目的 `completed`,不因其它项目或 `failed` 刷新画布。 - 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f5ce1bc21..8d0273ea8 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4243,6 +4243,13 @@ - 处理:并发 bootstrap 每次 attempt 使用独立 `AbortController`,任一分支失败时先中止同轮 controller 再安排有界重试,卸载时中止当前 attempt。充值订单从创建成功起持有同一个 owner、账号 revision 和 `AbortController`;每次 delay、confirm 和 SSE watch 前后都校验生命周期,并把同一 signal 传到请求层;账号切换和卸载先 abort,再清理 ref、state 与旧支付回调 hash。 - 验证:bootstrap 用例覆盖“一侧 reject、另一侧 pending、重试后卸载”,并断言每轮 signal 都已中止;充值用 fake timer 证明首次确认 pending 后切换账号会中止 signal,推进全部退避时间也不会产生第二个确认请求或清理新账号 Token。 +## 中止 refresh 等待不等于隔离 token 发布(2026-08-07) + +- 现象:A 账号的写请求 401 后开始共享 refresh,随后切换到 B。A 的 `AbortSignal` 虽然让业务请求立即结束且不再重放 POST,但底层 refresh 为了其它共享等待者不会被中止;A 的成功回包晚到时仍可能覆盖 B 的 token。 +- 原因:只对 `await` 叠加 abort 保护了调用链,没有给共享 Promise 的归属和最终 token 写入加账号栅栏;单一全局 Promise 还会让 B 加入 A 已在途的 refresh。 +- 处理:公开 token setter / clearer 每次都推进 auth generation;refresh 按 `generation + 发起时 access token` 共享、并以该快照 CAS 发布成功 token。快照已过期时成功回包转为失效结果,401/403 也不得清理新代际 token;新代际建立自己的 refresh Promise,旧 Promise 收尾时不得清掉新尝试。 +- 验证:`src/services/apiClient.test.ts` 要等旧 refresh 完整收束后断言 B token 不变,并用两个独立 deferred response 证明 B 会发起第二个 `/api/auth/refresh`;另覆盖旧 refresh 401 晚到不清 B token。 + ## 下游 manifest 回调测试不能冒充实时数据源(2026-08-05) - 现象:工作台的资源、任务与版本重投影单测保持绿色,但后台 Agent 已更新 `.agent/manifest.json` 后,打开中的工作台仍长期显示旧快照,只有重开项目才更新。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 6e2c68477..3b61c0ad2 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -66,7 +66,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 5. 前端不得用 `hasPointsRecharged` 统一隐藏所有泥点档位首充权益;该字段只表示账号是否发生过任一泥点充值。 6. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5` 或 `wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝。 7. 小程序 WebView 充值使用 `wechat_mp_virtual` 调起小程序虚拟支付;微信内浏览器使用 `wechat_jsapi` 调起微信支付 JSAPI;普通 Web 使用 `wechat_native` 二维码支付,避免因移动 UA、触控能力或窄屏误入 `wechat_h5`。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。一次充值从下单、宿主 / JSAPI / H5 / Native 调起到查单确认与 watch 必须始终携带同一个 `ownerUserId + account lifecycle revision`;pending / confirming order、二维码、提交状态、错误结果、成功回调以及清 token、重新登录等认证副作用在每次写入前都必须校验该令牌,账号切换或卸载后旧链路不得再影响新账号。 -8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。前端下单入口还必须使用同步 operation token 防止同一 React 提交周期内重复创建订单;充值下单、邀请码兑换和奖励码兑换必须绑定同一个账号生命周期 `AbortSignal`,切号或卸载时先中止旧 signal,禁止 POST 的 401、503 或网络重试重新读取新账号 Token。账号切换时,充值、账单、邀请码中心、邀请码输入、弹窗和在途读取结果都必须按账号生命周期整体失效。 +8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。前端下单入口还必须使用同步 operation token 防止同一 React 提交周期内重复创建订单;充值下单、邀请码兑换和奖励码兑换必须绑定同一个账号生命周期 `AbortSignal`,切号或卸载时先中止旧 signal,禁止 POST 的 401、503 或网络重试重新读取新账号 Token。共享 access token refresh 还必须按账号认证代际与发起时 token 快照隔离:旧代际成功回包不得发布 token,旧代际失败不得清理新账号 token,新账号不得复用旧账号的在途 refresh Promise。账号切换时,充值、账单、邀请码中心、邀请码输入、弹窗和在途读取结果都必须按账号生命周期整体失效。 9. 后台“充值商品”页继续维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。会员商品配置保留不表示当前版本开放公开购买或升级入口。 ## 唯一后端路线 diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index 2ef89976e..c1021f0d0 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -722,6 +722,14 @@ describe('apiClient', () => { await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + const accountARefresh = refreshStoredAccessToken({ + clearOnFailure: false, + }); + const accountARefreshResult = expect(accountARefresh).rejects.toMatchObject( + { + name: 'AuthStateChangedDuringRefreshError', + }, + ); setStoredAccessToken('account-b-token', { emit: false }); abortController.abort(); @@ -737,16 +745,85 @@ describe('apiClient', () => { }), }), ); - await refreshResponse.promise; - await Promise.resolve(); + await accountARefreshResult; expect(fetchMock).toHaveBeenCalledTimes(2); + expect(getStoredAccessToken()).toBe('account-b-token'); expect(fetchMock.mock.calls[0]?.[0]).toBe( '/api/profile/redeem-codes/redeem', ); expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/auth/refresh'); }); + it('starts a separate refresh for a new auth generation', async () => { + setStoredAccessToken('account-a-token', { emit: false }); + const accountAResponse = + createDeferred>(); + const accountBResponse = + createDeferred>(); + fetchMock + .mockImplementationOnce(() => accountAResponse.promise) + .mockImplementationOnce(() => accountBResponse.promise); + + const accountARefresh = refreshStoredAccessToken({ clearOnFailure: false }); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + setStoredAccessToken('account-b-token', { emit: false }); + const accountBRefresh = refreshStoredAccessToken({ clearOnFailure: false }); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + accountAResponse.resolve( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { token: 'late-account-a-token' }, + error: null, + meta: { apiVersion: '2026-06-16' }, + }), + }), + ); + await expect(accountARefresh).rejects.toMatchObject({ + name: 'AuthStateChangedDuringRefreshError', + }); + expect(getStoredAccessToken()).toBe('account-b-token'); + + accountBResponse.resolve( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { token: 'fresh-account-b-token' }, + error: null, + meta: { apiVersion: '2026-06-16' }, + }), + }), + ); + await expect(accountBRefresh).resolves.toBe('fresh-account-b-token'); + expect(getStoredAccessToken()).toBe('fresh-account-b-token'); + }); + + it('does not clear a new account token when an old refresh fails late', async () => { + setStoredAccessToken('account-a-token', { emit: false }); + const accountAResponse = + createDeferred>(); + fetchMock.mockImplementationOnce(() => accountAResponse.promise); + + const accountARefresh = refreshStoredAccessToken(); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + setStoredAccessToken('account-b-token', { emit: false }); + accountAResponse.resolve(createResponseMock({ status: 401 })); + + await expect(accountARefresh).rejects.toMatchObject({ status: 401 }); + expect(getStoredAccessToken()).toBe('account-b-token'); + }); + it('aborts requests when timeoutMs is reached', async () => { setStoredAccessToken('timeout-token', { emit: false }); fetchMock.mockImplementation( diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 94211f5e0..d889fb09b 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -114,7 +114,9 @@ function normalizeHeaders(headers?: HeadersInit) { function hasHeader(headers: Record, name: string) { const normalizedName = name.toLowerCase(); - return Object.keys(headers).some((key) => key.toLowerCase() === normalizedName); + return Object.keys(headers).some( + (key) => key.toLowerCase() === normalizedName, + ); } function setHeaderIfMissing( @@ -146,7 +148,11 @@ function attachHostRuntimeHeaders(headers: Record) { runtime.clientRuntime || 'wechat_mini_program', ); setHeaderIfMissing(headers, CLIENT_PLATFORM_HEADER, runtime.hostPlatform); - setHeaderIfMissing(headers, MINI_PROGRAM_ENV_HEADER, runtime.miniProgramEnv); + setHeaderIfMissing( + headers, + MINI_PROGRAM_ENV_HEADER, + runtime.miniProgramEnv, + ); return headers; } @@ -171,7 +177,10 @@ function buildClientRequestId() { return `web-${randomId}`; } -function resolveRequestIdHeader(headers: Record, options: ApiRequestOptions) { +function resolveRequestIdHeader( + headers: Record, + options: ApiRequestOptions, +) { const explicitRequestId = options.requestId?.trim(); const existingRequestId = Object.entries(headers).find( ([key, value]) => key.toLowerCase() === REQUEST_ID_HEADER && value.trim(), @@ -630,7 +639,9 @@ export function getStoredAccessToken() { return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || ''; } -export function setStoredAccessToken( +let authStateGeneration = 0; + +function writeStoredAccessToken( token: string, options: { emit?: boolean; @@ -654,6 +665,20 @@ export function setStoredAccessToken( } } +export function setStoredAccessToken( + token: string, + options: { + emit?: boolean; + } = {}, +) { + if (!canUseLocalStorage()) { + return; + } + + authStateGeneration += 1; + writeStoredAccessToken(token, options); +} + export function clearStoredAccessToken( options: { emit?: boolean; @@ -663,6 +688,7 @@ export function clearStoredAccessToken( return; } + authStateGeneration += 1; const previousToken = getStoredAccessToken(); window.localStorage.removeItem(ACCESS_TOKEN_KEY); @@ -687,7 +713,50 @@ function withAuthorizationHeaders( return nextHeaders; } -let refreshAccessTokenPromise: Promise | null = null; +type AuthStateSnapshot = { + generation: number; + accessToken: string; +}; + +type RefreshAccessTokenAttempt = AuthStateSnapshot & { + promise: Promise; +}; + +class AuthStateChangedDuringRefreshError extends Error { + constructor() { + super('刷新期间登录状态已变化'); + this.name = 'AuthStateChangedDuringRefreshError'; + } +} + +let refreshAccessTokenAttempt: RefreshAccessTokenAttempt | null = null; + +function captureAuthStateSnapshot(): AuthStateSnapshot { + return { + generation: authStateGeneration, + accessToken: getStoredAccessToken(), + }; +} + +function isCurrentAuthState(snapshot: AuthStateSnapshot) { + return ( + authStateGeneration === snapshot.generation && + getStoredAccessToken() === snapshot.accessToken + ); +} + +function publishRefreshedAccessToken( + nextToken: string, + snapshot: AuthStateSnapshot, +) { + if (!isCurrentAuthState(snapshot)) { + throw new AuthStateChangedDuringRefreshError(); + } + + // refresh 只轮换同一账号的 access token,不推进账号代际。 + // 外部登录、切号或退出通过公开 setter 推进代际,使旧 refresh 发布失效。 + writeStoredAccessToken(nextToken, { emit: false }); +} function shouldClearAuthAfterRefreshFailure(error: unknown) { return ( @@ -697,11 +766,16 @@ function shouldClearAuthAfterRefreshFailure(error: unknown) { } async function refreshAccessToken() { - if (refreshAccessTokenPromise) { - return refreshAccessTokenPromise; + const authStateSnapshot = captureAuthStateSnapshot(); + if ( + refreshAccessTokenAttempt && + refreshAccessTokenAttempt.generation === authStateSnapshot.generation && + refreshAccessTokenAttempt.accessToken === authStateSnapshot.accessToken + ) { + return refreshAccessTokenAttempt.promise; } - refreshAccessTokenPromise = (async () => { + const promise = (async () => { const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin', @@ -726,14 +800,21 @@ async function refreshAccessToken() { throw new Error('刷新登录状态失败'); } - setStoredAccessToken(nextToken, { emit: false }); + publishRefreshedAccessToken(nextToken, authStateSnapshot); return nextToken; })(); + const attempt: RefreshAccessTokenAttempt = { + ...authStateSnapshot, + promise, + }; + refreshAccessTokenAttempt = attempt; try { - return await refreshAccessTokenPromise; + return await promise; } finally { - refreshAccessTokenPromise = null; + if (refreshAccessTokenAttempt === attempt) { + refreshAccessTokenAttempt = null; + } } } @@ -752,12 +833,14 @@ export async function refreshStoredAccessToken( clearOnFailure?: boolean; } = {}, ) { + const authStateSnapshot = captureAuthStateSnapshot(); try { return await refreshAccessToken(); } catch (error) { if ( options.clearOnFailure !== false && - shouldClearAuthAfterRefreshFailure(error) + shouldClearAuthAfterRefreshFailure(error) && + isCurrentAuthState(authStateSnapshot) ) { clearStoredAccessToken({ emit: false }); } @@ -774,7 +857,10 @@ export async function fetchWithApiAuth( const retry = resolveRetryOptions(method, options.retry); const authFailurePolicy = resolveAuthFailurePolicy(options); const requestSignal = init.signal ?? undefined; - const requestId = resolveRequestIdHeader(normalizeHeaders(init.headers), options); + const requestId = resolveRequestIdHeader( + normalizeHeaders(init.headers), + options, + ); let attempt = 0; let refreshAttempted = false; @@ -784,7 +870,7 @@ export async function fetchWithApiAuth( requestHeaders[REQUEST_ID_HEADER] = requestId; let hasAuthHeader = Boolean( requestHeaders.Authorization?.trim() || - requestHeaders.authorization?.trim(), + requestHeaders.authorization?.trim(), ); if ( @@ -800,7 +886,7 @@ export async function fetchWithApiAuth( requestHeaders[REQUEST_ID_HEADER] = requestId; hasAuthHeader = Boolean( requestHeaders.Authorization?.trim() || - requestHeaders.authorization?.trim(), + requestHeaders.authorization?.trim(), ); } catch (error) { if (requestSignal?.aborted) { @@ -830,6 +916,7 @@ export async function fetchWithApiAuth( !authFailurePolicy.skipRefresh && !refreshAttempted ) { + const refreshAuthStateSnapshot = captureAuthStateSnapshot(); try { await awaitWithAbortSignal(refreshAccessToken(), requestSignal); refreshAttempted = true; @@ -844,7 +931,8 @@ export async function fetchWithApiAuth( const shouldClearAuth = hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized && - shouldClearAuthAfterRefreshFailure(refreshError); + shouldClearAuthAfterRefreshFailure(refreshError) && + isCurrentAuthState(refreshAuthStateSnapshot); if (shouldClearAuth) { clearStoredAccessToken({ emit: false }); } @@ -897,7 +985,9 @@ async function buildApiClientError( const baseMessage = parseApiErrorMessage(responseText, fallbackMessage); return new ApiClientError({ - message: requestId ? `${baseMessage}(requestId: ${requestId})` : baseMessage, + message: requestId + ? `${baseMessage}(requestId: ${requestId})` + : baseMessage, status: response.status, code: parsedError?.code ?? `HTTP_${response.status || 0}`, details: parsedError?.details ?? null, -- 2.52.0 From 341b333786345cd218ca665d1ca3b458caa685cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 21:33:19 +0800 Subject: [PATCH 36/36] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 Prettier 3.3.3 格式化鉴权头布尔判断续行 --- src/services/apiClient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index d889fb09b..880e52921 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -870,7 +870,7 @@ export async function fetchWithApiAuth( requestHeaders[REQUEST_ID_HEADER] = requestId; let hasAuthHeader = Boolean( requestHeaders.Authorization?.trim() || - requestHeaders.authorization?.trim(), + requestHeaders.authorization?.trim(), ); if ( @@ -886,7 +886,7 @@ export async function fetchWithApiAuth( requestHeaders[REQUEST_ID_HEADER] = requestId; hasAuthHeader = Boolean( requestHeaders.Authorization?.trim() || - requestHeaders.authorization?.trim(), + requestHeaders.authorization?.trim(), ); } catch (error) { if (requestSignal?.aborted) { -- 2.52.0