From f1f2332e619d10bcf4d7c0f7b2ae01acf349b6b7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 20 Jul 2026 12:48:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B3=A5=E7=82=B9=E4=BD=99?= =?UTF-8?q?=E9=A2=9D=E6=98=8E=E7=BB=86=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 钱包明细每次展开并在实时余额变化时读取最新拆分。 图片画板生成扣费或退款后同步刷新总额与充值中心。 充值中心读取增加 latest-wins 门禁并阻止旧响应覆盖权威结果。 补充钱包刷新、画板入口和充值乱序回归测试。 同步更新泥点统一资产入口决策记录。 --- .../shared-memory/decision-log.md | 2 +- .../PlatformMudPointWalletEntry.test.tsx | 57 +++++++++++- .../PlatformMudPointWalletEntry.tsx | 30 ++++-- .../image-editor/ImageCanvasEditorView.tsx | 10 +- .../ImageCanvasTopbarView.test.tsx | 2 +- .../usePlatformProfileCenterController.ts | 54 ++++++++--- .../RpgEntryHomeView.recharge.test.tsx | 92 +++++++++++++++++++ 7 files changed, 220 insertions(+), 27 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8276f29eb..1ff82d353 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -184,7 +184,7 @@ ## 2026-07-12 泥点充值收敛为四档并统一资产入口 - 背景:主站与图片画板的泥点余额入口、余额明细和充值弹窗存在不同实现,旧充值口径仍展示六档泥点、首充双倍和会员购买 / 升级入口,容易让展示、商品资格与后端余额真相发生漂移。 -- 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。默认泥点商品收敛为 `60 / ¥6`、`180 + 90 / ¥18`、`300 + 150 / ¥30`、`680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。 +- 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。钱包明细每次展开都重新读取充值中心 BFF,打开期间实时总额变化时继续补读;图片画板的生成扣费或退款完成后同时刷新总额与充值中心拆分。充值中心读请求必须使用 revision 门禁,支付创建、到账确认等权威响应写入时使旧读失效,避免旧响应覆盖新的每日免费 / 不限时明细。默认泥点商品收敛为 `60 / ¥6`、`180 + 90 / ¥18`、`300 + 150 / ¥30`、`680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。 - 影响范围:`profile_recharge_product_config` 默认商品、充值中心 read model、共享前后端契约、主站与图片画板泥点资产入口、充值弹窗、后台充值商品默认值。 - 验证方式:充值与统一入口定向前端测试、`npm run typecheck`、充值商品定向 Rust 测试、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx index dbace6b73..de0c34f56 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry.test.tsx @@ -57,7 +57,7 @@ test('shows only permanent and daily free points in the shared wallet panel', as expect(within(details).getByText('每日免费泥点')).toBeTruthy(); expect(within(details).getByText('27')).toBeTruthy(); expect(within(details).getByText('每天重置为 20 泥点')).toBeTruthy(); - expect(onRequestDetails).not.toHaveBeenCalled(); + expect(onRequestDetails).toHaveBeenCalledTimes(1); await user.click(within(details).getByRole('button', { name: '使用详情' })); expect(onOpenLedger).toHaveBeenCalledTimes(1); @@ -85,6 +85,61 @@ test('keeps the chip on the live balance when cached details are stale', () => { expect(screen.queryByRole('button', { name: '泥点 207' })).toBeNull(); }); +test('refreshes cached balance details while open and after reopening', () => { + const onRequestDetails = vi.fn(); + const { rerender } = render( + , + ); + + const balanceButton = screen.getByRole('button', { name: '泥点 207' }); + fireEvent.mouseEnter(balanceButton); + expect(onRequestDetails).toHaveBeenCalledTimes(1); + + rerender( + , + ); + + expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy(); + expect(onRequestDetails).toHaveBeenCalledTimes(2); + + fireEvent.pointerDown(document.body); + fireEvent.mouseEnter(screen.getByRole('button', { name: '泥点 195' })); + expect(onRequestDetails).toHaveBeenCalledTimes(3); +}); + +test('requests missing details after the opening balance load completes', () => { + const onRequestDetails = vi.fn(); + const props = { + balance: 20, + breakdown: null, + variant: 'mobile' as const, + onRequestDetails, + onRecharge: vi.fn(), + onOpenLedger: vi.fn(), + }; + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '泥点 20' })); + expect(onRequestDetails).not.toHaveBeenCalled(); + + rerender(); + expect(onRequestDetails).toHaveBeenCalledTimes(1); +}); + test('keeps the desktop panel open while moving across the gap without click pinning', () => { vi.useFakeTimers(); diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry.tsx index 57190b210..dd814d33a 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry.tsx @@ -64,6 +64,7 @@ export function PlatformMudPointWalletEntry({ }: PlatformMudPointWalletEntryProps) { const rootRef = useRef(null); const isOpenRef = useRef(false); + const requestedDetailsKeyForOpenRef = useRef(null); const closeTimerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const isCompact = variant === 'mobile'; @@ -74,6 +75,7 @@ export function PlatformMudPointWalletEntry({ : formatMudPointCount(displayedBalance, true); const exactBalanceLabel = displayedBalance === null ? '--' : formatMudPointCount(displayedBalance); + const detailsRequestKey = `balance:${balance ?? breakdown?.totalPoints ?? 'unknown'}`; const cancelPendingClose = useCallback(() => { if (closeTimerRef.current !== null) { @@ -85,9 +87,21 @@ export function PlatformMudPointWalletEntry({ const closeDetails = useCallback(() => { cancelPendingClose(); isOpenRef.current = false; + requestedDetailsKeyForOpenRef.current = null; setIsOpen(false); }, [cancelPendingClose]); + const requestDetailsIfNeeded = useCallback(() => { + if ( + isLoading || + requestedDetailsKeyForOpenRef.current === detailsRequestKey + ) { + return; + } + requestedDetailsKeyForOpenRef.current = detailsRequestKey; + onRequestDetails(); + }, [detailsRequestKey, isLoading, onRequestDetails]); + const requestAndOpen = useCallback(() => { cancelPendingClose(); if (isOpenRef.current) { @@ -95,13 +109,17 @@ export function PlatformMudPointWalletEntry({ } isOpenRef.current = true; setIsOpen(true); - if (!breakdown && !isLoading) { - onRequestDetails(); - } - }, [breakdown, cancelPendingClose, isLoading, onRequestDetails]); + requestDetailsIfNeeded(); + }, [cancelPendingClose, requestDetailsIfNeeded]); useEffect(() => cancelPendingClose, [cancelPendingClose]); + useEffect(() => { + if (isOpen) { + requestDetailsIfNeeded(); + } + }, [isOpen, requestDetailsIfNeeded]); + useEffect(() => { if (!isOpen) { return; @@ -151,9 +169,7 @@ export function PlatformMudPointWalletEntry({
{ diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index cc46041ba..8cf096b11 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -522,6 +522,10 @@ export function ImageCanvasEditorView({ requestLogin: () => authUiRef.current?.openLoginModal(), currentUser: authUi?.user ?? null, }); + const refreshEditorWalletState = useCallback(() => { + refreshEditorWalletBalance(); + loadRechargeCenter(); + }, [loadRechargeCenter, refreshEditorWalletBalance]); const isAccountPaymentModalOpen = isRewardCodeOpen || isRechargeOpen || @@ -1239,7 +1243,7 @@ export function ImageCanvasEditorView({ assetFolderId: activeUploadFolderId, upsertGeneratedAsset, applyProjectSnapshot: applyGeneratedProjectSnapshot, - onWalletBalanceMayHaveChanged: refreshEditorWalletBalance, + onWalletBalanceMayHaveChanged: refreshEditorWalletState, }); const handleEditorAgentConfirmSent = useCallback(() => { generationSurface.refreshTaskList(); @@ -1258,7 +1262,7 @@ export function ImageCanvasEditorView({ if (warning) { showGenerationWarning(warning); } - refreshEditorWalletBalance(); + refreshEditorWalletState(); void loadEditorProject(projectId) .then(applyGeneratedProjectSnapshot) .catch(() => undefined); @@ -1266,7 +1270,7 @@ export function ImageCanvasEditorView({ [ applyGeneratedProjectSnapshot, projectId, - refreshEditorWalletBalance, + refreshEditorWalletState, showGenerationWarning, ], ); diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index c9eb10d4b..2ff3eeaf5 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -120,7 +120,7 @@ describe('ImageCanvasTopbarView', () => { await user.click(screen.getByRole('button', { name: '充值' })); expect(props.onRecharge).toHaveBeenCalledTimes(1); - expect(props.onRequestWalletDetails).not.toHaveBeenCalled(); + expect(props.onRequestWalletDetails).toHaveBeenCalledTimes(1); }); it('shows the current user avatar beside the mud point balance', () => { diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index dafbd71c9..5dc5e7a58 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -385,6 +385,7 @@ export function usePlatformProfileCenterController({ useCopyFeedback(); const pendingWechatRechargeOrderIdRef = useRef(null); const confirmingWechatRechargeOrderIdRef = useRef(null); + const rechargeCenterReadRevisionRef = useRef(0); // 中文注释:支持带邀请码 query 的直达场景,登录成功后自动打开兑换面板并复用同一套输入状态。 useEffect(() => { @@ -426,18 +427,40 @@ export function usePlatformProfileCenterController({ loadWalletLedger(); }, [loadWalletLedger]); + const applyRechargeCenter = useCallback( + (center: ProfileRechargeCenterResponse) => { + rechargeCenterReadRevisionRef.current += 1; + setIsLoadingRechargeCenter(false); + setRechargeError(null); + setRechargeCenter(center); + }, + [], + ); + const loadRechargeCenter = useCallback(() => { + const revision = ++rechargeCenterReadRevisionRef.current; setRechargeError(null); setIsLoadingRechargeCenter(true); void getRpgProfileRechargeCenter() - .then(setRechargeCenter) + .then((center) => { + if (revision === rechargeCenterReadRevisionRef.current) { + setRechargeCenter(center); + } + }) .catch((error: unknown) => { + if (revision !== rechargeCenterReadRevisionRef.current) { + return; + } setRechargeCenter(null); setRechargeError( error instanceof Error ? error.message : '读取泥点购买信息失败', ); }) - .finally(() => setIsLoadingRechargeCenter(false)); + .finally(() => { + if (revision === rechargeCenterReadRevisionRef.current) { + setIsLoadingRechargeCenter(false); + } + }); }, []); const refreshRechargeState = useCallback(() => { @@ -482,7 +505,7 @@ export function usePlatformProfileCenterController({ .then((response) => { const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; - setRechargeCenter(response.center); + applyRechargeCenter(response.center); pendingWechatRechargeOrderIdRef.current = null; confirmingWechatRechargeOrderIdRef.current = null; setWechatRechargeOrderConfirmationState(null); @@ -525,7 +548,7 @@ export function usePlatformProfileCenterController({ clearWechatPayResultHash(); return true; - }, [onRechargeSuccess, refreshRechargeState]); + }, [applyRechargeCenter, onRechargeSuccess, refreshRechargeState]); const pollWechatPayResultFromHash = useCallback( () => handleWechatPayResult(), @@ -549,7 +572,7 @@ export function usePlatformProfileCenterController({ .then((response) => { const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; - setRechargeCenter(response.center); + applyRechargeCenter(response.center); pendingWechatRechargeOrderIdRef.current = null; confirmingWechatRechargeOrderIdRef.current = null; setWechatRechargeOrderConfirmationState(null); @@ -569,7 +592,7 @@ export function usePlatformProfileCenterController({ }); }); return true; - }, [nativeWechatPayment, onRechargeSuccess]); + }, [applyRechargeCenter, nativeWechatPayment, onRechargeSuccess]); const openRechargeModal = useCallback(() => { if (!currentUser) { @@ -625,7 +648,7 @@ export function usePlatformProfileCenterController({ .then(async (response) => { if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) { pendingWechatRechargeOrderIdRef.current = response.order.orderId; - setRechargeCenter(response.center); + applyRechargeCenter(response.center); const paymentHandled = await requestHostPayment({ payload: response.wechatMiniProgramPayParams, orderId: response.order.orderId, @@ -637,7 +660,7 @@ export function usePlatformProfileCenterController({ } if (paymentChannel === WECHAT_JSAPI_PAYMENT_CHANNEL) { pendingWechatRechargeOrderIdRef.current = response.order.orderId; - setRechargeCenter(response.center); + applyRechargeCenter(response.center); setRechargePaymentResult({ kind: 'pending', title: '正在打开微信支付', @@ -657,7 +680,7 @@ export function usePlatformProfileCenterController({ confirmResponse.order, ); const isPaid = result.kind === 'success'; - setRechargeCenter(confirmResponse.center); + applyRechargeCenter(confirmResponse.center); setRechargePaymentResult(result); if (result.kind !== 'pending') { pendingWechatRechargeOrderIdRef.current = null; @@ -681,7 +704,7 @@ export function usePlatformProfileCenterController({ throw new Error('微信 H5 支付链接生成失败'); } pendingWechatRechargeOrderIdRef.current = response.order.orderId; - setRechargeCenter(response.center); + applyRechargeCenter(response.center); setRechargePaymentResult({ kind: 'pending', title: '正在打开微信支付', @@ -698,7 +721,7 @@ export function usePlatformProfileCenterController({ throw new Error('微信 Native 支付链接生成失败'); } pendingWechatRechargeOrderIdRef.current = response.order.orderId; - setRechargeCenter(response.center); + applyRechargeCenter(response.center); setNativeWechatPayment({ ...wechatNativePayment, codeUrl, @@ -764,7 +787,7 @@ export function usePlatformProfileCenterController({ setSubmittingRechargeProductId(null); }); }, - [onRechargeSuccess, submittingRechargeProductId], + [applyRechargeCenter, onRechargeSuccess, submittingRechargeProductId], ); const confirmNativeWechatPayment = useCallback(() => { @@ -787,7 +810,7 @@ export function usePlatformProfileCenterController({ } const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; - setRechargeCenter(response.center); + applyRechargeCenter(response.center); if (result.kind !== 'pending') { setNativeWechatPayment(null); pendingWechatRechargeOrderIdRef.current = null; @@ -819,7 +842,7 @@ export function usePlatformProfileCenterController({ ); }) .finally(() => setSubmittingRechargeProductId(null)); - }, [nativeWechatPayment, onRechargeSuccess]); + }, [applyRechargeCenter, nativeWechatPayment, onRechargeSuccess]); useEffect(() => { const orderId = nativeWechatPayment?.orderId; @@ -845,7 +868,7 @@ export function usePlatformProfileCenterController({ } const result = buildRechargePaymentResultForOrder(response.order); - setRechargeCenter(response.center); + applyRechargeCenter(response.center); if (result.kind === 'pending') { await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS); continue; @@ -882,6 +905,7 @@ export function usePlatformProfileCenterController({ }, [ nativeWechatPayment?.expiresAt, nativeWechatPayment?.orderId, + applyRechargeCenter, onRechargeSuccess, ]); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index d9873356c..699cdfe97 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -4465,6 +4465,98 @@ test('logged in mobile recommend page exposes the shared wallet details and rech expect(within(rechargeDialog).queryByText('会员月卡')).toBeNull(); }); +test('the latest recharge center read wins when wallet and recharge overlap', async () => { + const user = userEvent.setup(); + mockNarrowMobileLayout(); + let resolveWalletRead!: ( + center: ProfileRechargeCenterResponse, + ) => void; + let resolveRechargeRead!: ( + center: ProfileRechargeCenterResponse, + ) => void; + const walletReadPromise = new Promise( + (resolve) => { + resolveWalletRead = resolve; + }, + ); + const rechargeReadPromise = new Promise( + (resolve) => { + resolveRechargeRead = resolve; + }, + ); + mockGetRpgProfileRechargeCenter + .mockReturnValueOnce(walletReadPromise) + .mockReturnValueOnce(rechargeReadPromise); + + const { container } = render( + , + ); + const walletLayer = container.querySelector( + '.platform-mobile-recommend-wallet-entry', + ); + expect(walletLayer).toBeTruthy(); + + await user.click( + within(walletLayer as HTMLElement).getByRole('button', { + name: '泥点 207', + }), + ); + const rechargeButtons = within(walletLayer as HTMLElement).getAllByRole( + 'button', + { name: '充值' }, + ); + await user.click(rechargeButtons[0]!); + + expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(2); + + resolveRechargeRead({ + walletBalance: 207, + mudPointBalance: { + totalPoints: 207, + permanentPoints: 187, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 20, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-07-20T16:00:00Z', + }, + membership: buildNormalMembership(), + pointProducts: [buildPointProduct()], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }); + + expect(await screen.findByText('购买更多泥点')).toBeTruthy(); + expect(screen.getByText('当前余额 207 泥点')).toBeTruthy(); + + resolveWalletRead({ + walletBalance: 0, + mudPointBalance: { + totalPoints: 0, + permanentPoints: 0, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-07-20T16:00:00Z', + }, + membership: buildNormalMembership(), + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }); + + await act(async () => undefined); + expect(screen.getByText('当前余额 207 泥点')).toBeTruthy(); +}); + test('mobile discover search submits public work code', async () => { const user = userEvent.setup(); const onSearchPublicCode = vi.fn();