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] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=BC=E5=AE=B9=E9=92=B1?= =?UTF-8?q?=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()}