修复兼容钱包总额展示
向个人中心和图片画板传递 owner 匹配的 legacy 总额 保持钱包分桶与账单明细为空避免伪造数据 补充个人中心和图片画板回归测试及契约文档
This commit is contained in:
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user