diff --git a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md index 7d16625d..693b0369 100644 --- a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md +++ b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md @@ -26,6 +26,8 @@ 泥点充值固定为 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。全部档位参与首充双倍:用户历史上没有 `points_recharge` 流水时,本次购买到账泥点为基础泥点与等额赠送泥点之和;已有充值流水后只到账基础泥点。实际到账泥点写入交易流水,余额以 SpacetimeDB projection 为准。 +充值中心返回的 `hasPointsRecharged` 是首充资格的展示与结算共同依据:当它为 `true` 时,后端下发的泥点套餐应只保留基础泥点、清空首充徽标与赠送文案;前端即使收到旧版本快照中残留的 `bonusPoints` / `badgeLabel`,也必须按 `hasPointsRecharged` 隐藏“首充双倍”和 `基础+赠送` 展示。这样可以避免第二次充值只到账基础泥点时,弹窗仍显示 `60+60` 等已失效权益。 + ### 2.2 会员卡套餐 | productId | 类型 | 天数 | 金额分 | 权益 | @@ -144,9 +146,9 @@ 4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`。 - 小程序 web-view 内的 H5 只负责加载微信 JS-SDK 并通过 `wx.miniProgram.navigateTo` 跳转到 `/pages/wechat-pay/index`;实际支付必须在小程序 native 页调用 `wx.requestPayment`,不要切换为 H5 支付产品。 - native 支付页通过 `wx_pay_result=:success|cancel|fail` 回填 web-view;H5 在 `hashchange`、`focus`、`pageshow` 和 `visibilitychange` 中都会尝试消费该结果,避免小程序返回 web-view 时没有触发单一事件导致状态不刷新。 - - `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。 - - 小程序返回后,前端会对确认接口做短轮询,覆盖微信通知/查单结果与 web-view 恢复之间的秒级时间差;只有确认响应里的订单状态变成 `paid` 后,才触发父级 `profileDashboard` 刷新,确保“我的”页泥点卡片读取到最新余额。 - - `cancel` 和 `fail` 只复位按钮、刷新账户中心并通过全局支付结果模态展示,不调用入账逻辑。 + - `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。 + - 小程序返回后,前端会对确认接口做短轮询,覆盖微信通知/查单结果与 web-view 恢复之间的秒级时间差;只有确认响应里的订单状态变成 `paid` 后,才触发父级 `profileDashboard` 刷新,确保“我的”页泥点卡片读取到最新余额。 + - `cancel` 和 `fail` 只复位按钮、刷新账户中心并通过全局支付结果模态展示,不调用入账逻辑。 5. 支付结果使用页面级全局模态展示,不写回商品卡片或账户充值弹窗内部;充值弹窗只负责套餐选择、加载失败和下单失败。 6. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和操作状态。 7. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 @@ -156,5 +158,6 @@ 1. 普通用户打开弹窗能看到泥点与会员套餐。 2. 泥点购买后余额增加,流水来源为 `points_recharge`。 3. 首充赠送只在首次泥点充值时生效。 -4. 会员购买后会员状态与到期时间立即更新。 -5. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。 +4. 已产生 `points_recharge` 流水后,再打开充值弹窗不应展示“首充双倍”徽标或 `60+60` 等赠送泥点组合。 +5. 会员购买后会员状态与到期时间立即更新。 +6. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。 diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index a9a948ad..ff9c21b0 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -77,6 +77,21 @@ pub fn runtime_profile_recharge_point_products() -> Vec Vec { + let mut products = runtime_profile_recharge_point_products(); + if has_points_recharged { + for product in &mut products { + product.bonus_points = 0; + product.badge_label.clear(); + product.description = product.title.clone(); + } + } + products +} + pub fn runtime_profile_recharge_membership_products() -> Vec { vec![ @@ -706,6 +721,22 @@ mod tests { ); } + #[test] + fn recharge_point_products_resolve_effective_first_bonus_display() { + let first_recharge_products = resolve_runtime_profile_recharge_point_products(false); + assert_eq!(first_recharge_products[0].bonus_points, 60); + assert_eq!(first_recharge_products[0].badge_label, "首充双倍"); + assert_eq!(first_recharge_products[0].description, "首充送60泥点"); + + let repeated_recharge_products = resolve_runtime_profile_recharge_point_products(true); + assert_eq!(repeated_recharge_products[0].bonus_points, 0); + assert_eq!(repeated_recharge_products[0].badge_label, ""); + assert_eq!(repeated_recharge_products[0].description, "60泥点"); + assert_eq!(repeated_recharge_products[5].bonus_points, 0); + assert_eq!(repeated_recharge_products[5].badge_label, ""); + assert_eq!(repeated_recharge_products[5].description, "3280泥点"); + } + #[test] fn build_recharge_order_input_rejects_unknown_product() { let error = build_runtime_profile_recharge_order_create_input( diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 656e023b..f47134fe 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -2915,16 +2915,18 @@ fn build_profile_recharge_center_snapshot( .map(|row| row.wallet_balance) .unwrap_or(0); + let has_points_recharged = has_profile_points_recharged(ctx, user_id); + RuntimeProfileRechargeCenterSnapshot { user_id: user_id.to_string(), wallet_balance, membership: build_profile_membership_snapshot(ctx, user_id), - point_products: runtime_profile_recharge_point_products(), + point_products: resolve_runtime_profile_recharge_point_products(has_points_recharged), membership_products: runtime_profile_recharge_membership_products(), benefits: runtime_profile_membership_benefits(), latest_order: latest_profile_recharge_order(ctx, user_id) .map(|row| build_profile_recharge_order_snapshot_from_row(&row)), - has_points_recharged: has_profile_points_recharged(ctx, user_id), + has_points_recharged, } } diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 7a45adee..583fbdea 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -675,6 +675,10 @@ function renderProfileView( ); } +async function openRechargeModal(user: ReturnType) { + await user.click(screen.getByRole('button', { name: /充值\s*泥点\/会员/u })); +} + function renderLoggedOutHomeView( openLoginModal = vi.fn(), overrides: Partial< @@ -924,7 +928,9 @@ afterEach(() => { vi.unstubAllGlobals(); window.wx = undefined; document - .querySelectorAll('script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]') + .querySelectorAll( + 'script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]', + ) .forEach((script) => script.remove()); mockGetRpgProfileReferralInviteCenter.mockResolvedValue( mockBuildReferralCenter(), @@ -1016,10 +1022,7 @@ test('profile recharge modal buys points through mock channel outside mini progr const onRechargeSuccess = vi.fn(); renderProfileView(onRechargeSuccess); - const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); - await user.click( - within(shortcutRegion).getByRole('button', { name: /充值/u }), - ); + await openRechargeModal(user); expect(await screen.findByText('账户充值')).toBeTruthy(); expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1); @@ -1031,13 +1034,52 @@ test('profile recharge modal buys points through mock channel outside mini progr 'mock', ); }); - expect( - await screen.findByRole('dialog', { name: '支付成功' }), - ).toBeTruthy(); + expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy(); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); +test('profile recharge modal hides first bonus display after points recharge', async () => { + const user = userEvent.setup(); + mockGetRpgProfileRechargeCenter.mockResolvedValueOnce({ + walletBalance: 60, + membership: { + status: 'normal', + tier: 'normal', + startedAt: null, + expiresAt: null, + updatedAt: null, + }, + pointProducts: [ + { + productId: 'points_60', + title: '60泥点', + priceCents: 600, + kind: 'points', + pointsAmount: 60, + bonusPoints: 60, + durationDays: 0, + badgeLabel: '首充双倍', + description: '首充送60泥点', + tier: 'normal', + }, + ], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: true, + }); + + renderProfileView(); + await openRechargeModal(user); + + const rechargeDialog = await screen.findByText('账户充值'); + expect(rechargeDialog).toBeTruthy(); + expect(screen.getByRole('button', { name: /60泥点/u })).toBeTruthy(); + expect(screen.queryByText('首充双倍')).toBeNull(); + expect(screen.queryByText('60+60泥点')).toBeNull(); +}); + test('profile recharge modal posts requestPayment params in mini program web-view', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); @@ -1090,10 +1132,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie }); renderProfileView(onRechargeSuccess); - const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); - await user.click( - within(shortcutRegion).getByRole('button', { name: /充值/u }), - ); + await openRechargeModal(user); await user.click(await screen.findByRole('button', { name: /60泥点/u })); await waitFor(() => { @@ -1118,9 +1157,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie }); expect(navigateUrl).toContain('order-wechat-1'); expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay'); - expect( - await screen.findByRole('dialog', { name: '支付成功' }), - ).toBeTruthy(); + expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy(); expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( 'order-wechat-1', @@ -1243,10 +1280,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb }); renderProfileView(onRechargeSuccess); - const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); - await user.click( - within(shortcutRegion).getByRole('button', { name: /充值/u }), - ); + await openRechargeModal(user); await user.click(await screen.findByRole('button', { name: /60泥点/u })); const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; @@ -1266,9 +1300,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(2); }); - expect( - await screen.findByRole('dialog', { name: '支付成功' }), - ).toBeTruthy(); + expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); @@ -1319,10 +1351,7 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri }); renderProfileView(); - const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); - await user.click( - within(shortcutRegion).getByRole('button', { name: /充值/u }), - ); + await openRechargeModal(user); await user.click(await screen.findByRole('button', { name: /60泥点/u })); await waitFor(() => { @@ -1354,9 +1383,7 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri window.location.hash = `wx_pay_result=${requestId}:success`; window.dispatchEvent(new HashChangeEvent('hashchange')); }); - expect( - await screen.findByRole('dialog', { name: '支付成功' }), - ).toBeTruthy(); + expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); }); test('profile recharge modal releases submitting state after cancelled wechat pay result', async () => { @@ -1410,10 +1437,7 @@ test('profile recharge modal releases submitting state after cancelled wechat pa }); renderProfileView(); - const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); - await user.click( - within(shortcutRegion).getByRole('button', { name: /充值/u }), - ); + await openRechargeModal(user); const buyButton = await screen.findByRole('button', { name: /60泥点/u }); await user.click(buyButton); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 1c7f9e06..2eeb53cb 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -1,6 +1,6 @@ import { - ArrowRight, AlertCircle, + ArrowRight, BookOpen, Camera, CheckCircle2, @@ -16,8 +16,8 @@ import { Heart, LogIn, MessageCircle, - Pencil, Palette, + Pencil, Plus, Search, Settings, @@ -47,22 +47,22 @@ import communityQqQrImage from '../../../media/social-media-group/qq.png'; import communityWechatQrImage from '../../../media/social-media-group/wechat.png'; import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth'; import type { + ConfirmWechatProfileRechargeOrderResponse, CustomWorldLibraryEntry, PlatformBrowseHistoryEntry, - ConfirmWechatProfileRechargeOrderResponse, ProfileDashboardCardKey, ProfileDashboardSummary, ProfilePlayedWorkSummary, ProfilePlayStatsResponse, - ProfileReferralInviteCenterResponse, ProfileRechargeCenterResponse, ProfileRechargeProduct, - WechatMiniProgramPayParams, + ProfileReferralInviteCenterResponse, ProfileSaveArchiveSummary, ProfileTaskCenterResponse, ProfileTaskItem, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse, + WechatMiniProgramPayParams, } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes'; @@ -77,8 +77,8 @@ import { claimRpgProfileTaskReward, confirmWechatRpgProfileRechargeOrder, createRpgProfileRechargeOrder, - getRpgProfileReferralInviteCenter, getRpgProfileRechargeCenter, + getRpgProfileReferralInviteCenter, getRpgProfileTasks, getRpgProfileWalletLedger, redeemRpgProfileReferralInviteCode, @@ -2498,17 +2498,23 @@ async function confirmWechatRechargeOrderUntilSettled( function RechargeProductCard({ product, + hasPointsRecharged, submittingProductId, onBuy, }: { product: ProfileRechargeProduct; + hasPointsRecharged: boolean; submittingProductId: string | null; onBuy: (product: ProfileRechargeProduct) => void; }) { const submitting = submittingProductId === product.productId; + const effectiveBonusPoints = + product.kind === 'points' && hasPointsRecharged ? 0 : product.bonusPoints; + const badgeLabel = + product.kind === 'points' && hasPointsRecharged ? '' : product.badgeLabel; const value = product.kind === 'points' - ? `${product.pointsAmount}${product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''}泥点` + ? `${product.pointsAmount}${effectiveBonusPoints > 0 ? `+${effectiveBonusPoints}` : ''}泥点` : `${product.durationDays}天`; return ( @@ -2518,9 +2524,9 @@ function RechargeProductCard({ disabled={Boolean(submittingProductId)} className="platform-subpanel platform-interactive-card relative min-h-[7.25rem] rounded-[1.15rem] px-3.5 py-3.5 text-left disabled:cursor-not-allowed disabled:opacity-60" > - {product.badgeLabel ? ( + {badgeLabel ? ( - {product.badgeLabel} + {badgeLabel} ) : null}
@@ -2640,6 +2646,7 @@ function ProfileRechargeModal({ @@ -3928,7 +3935,7 @@ export function RpgEntryHomeView({ setIsWalletLedgerOpen(true); loadWalletLedger(); }; - const loadRechargeCenter = () => { + const loadRechargeCenter = useCallback(() => { setRechargeError(null); setIsLoadingRechargeCenter(true); void getRpgProfileRechargeCenter() @@ -3940,15 +3947,12 @@ export function RpgEntryHomeView({ ); }) .finally(() => setIsLoadingRechargeCenter(false)); - }; - const refreshRechargeState = useCallback( - () => { - loadRechargeCenter(); - setSubmittingRechargeProductId(null); - pendingWechatRechargeOrderIdRef.current = null; - }, - [loadRechargeCenter], - ); + }, []); + const refreshRechargeState = useCallback(() => { + loadRechargeCenter(); + setSubmittingRechargeProductId(null); + pendingWechatRechargeOrderIdRef.current = null; + }, [loadRechargeCenter]); const handleWechatPayResult = useCallback(() => { const payResult = readWechatPayResultFromHash(); if (!payResult) {