修复兼容钱包总额展示

向个人中心和图片画板传递 owner 匹配的 legacy 总额

保持钱包分桶与账单明细为空避免伪造数据

补充个人中心和图片画板回归测试及契约文档
This commit is contained in:
2026-08-07 10:27:37 +08:00
parent ab66a50cfa
commit 58bf56ec56
9 changed files with 90 additions and 10 deletions
@@ -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`
@@ -9,7 +9,7 @@
- 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。
- 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=<projectId>`
- 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。
- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额优先展示画板按扣费、退回、到账和页面恢复链路刷新的 `profileDashboard.walletBalance`,充值中心 `mudPointBalance` 仅用于展开面板的账户明细,不覆盖已刷新的总余额。余额区只展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。
- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额读取 owner 匹配的钱包 `mudPointBalance.totalPoints`;兼容响应只有 `walletBalance` 时只将该 legacy 总额传给顶部收起态展示,不写回共享钱包,也不伪造不限时、每日免费或重置分桶。余额区只在真实 `mudPointBalance` 存在时展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
- 中央画布支持背景拖拽平移、滚轮二维平移、`Ctrl / Cmd + 滚轮` 缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
@@ -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` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。
@@ -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(
<AuthUiContext.Provider
value={createAuthValue({
user: {
id: 'user-1',
publicUserCode: 'U001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
})}
>
<ImageCanvasEditorView legacyWalletBalance={37} />
</AuthUiContext.Provider>,
);
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');
@@ -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<HTMLElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const assetListRef = useRef<HTMLDivElement | null>(null);
@@ -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}
/>,
@@ -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}
@@ -99,7 +99,16 @@ vi.mock('../project/ProjectGalleryView', () => ({
}));
vi.mock('../image-editor/ImageCanvasEditorView', () => ({
ImageCanvasEditorView: () => <main aria-label="图片画布编辑器" />,
ImageCanvasEditorView: ({
legacyWalletBalance,
}: {
legacyWalletBalance?: number | null;
}) => (
<main
aria-label="图片画布编辑器"
data-legacy-wallet-balance={legacyWalletBalance ?? ''}
/>
),
}));
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(
<PlatformEntryFlowShellImpl
selectionStage="profile"
setSelectionStage={vi.fn()}
/>,
);
expect(
await screen.findByRole('button', { name: '泥点余额 37' }),
).toBeTruthy();
expect(await screen.findByText('37泥点')).toBeTruthy();
expect(screen.getByText('暂无账单记录')).toBeTruthy();
rerender(
<PlatformEntryFlowShellImpl
selectionStage="image-editor"
setSelectionStage={vi.fn()}
/>,
);
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 () => {
@@ -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({
<div className="image-editor-stage-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
<Suspense fallback={<LoadingPanel label="正在加载编辑器..." />}>
<ImageCanvasEditorView
legacyWalletBalance={legacyWalletBalance}
onProjectAccessLost={replaceWithProjectGallery}
/>
</Suspense>
@@ -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()}