diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 4c28bed3a..301bb553a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -35,10 +35,7 @@ import { import { PlatformMudPointWalletEntry, } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry'; -import { - PlatformProfileRechargeModal, - type PlatformProfileRechargeNativePaymentState, -} from '../../../packages/shared/src/components/PlatformProfileRechargeModal'; +import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal'; import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal'; import type { AuthEntryResponse, @@ -75,8 +72,6 @@ import { selectGameCreationAppReadyTasks, } from '../../../packages/shared/src/contracts/gameCreationApp'; import type { - ProfileRechargeCenterResponse, - ProfileRechargeProduct, ProfileWalletLedgerResponse, } from '../../../packages/shared/src/contracts/runtime'; import { @@ -84,12 +79,8 @@ import { API_RESPONSE_ENVELOPE_VERSION, unwrapApiResponse, } from '../../../packages/shared/src/http'; -import { - confirmClientWechatProfileRechargeOrder, - createClientProfileRechargeOrder, - getClientProfileRechargeCenter, - getClientProfileWalletLedger, -} from './services/clientApi'; +import { useRechargeController } from './hooks/useRechargeController'; +import { getClientProfileWalletLedger } from './services/clientApi'; import { useWalletStore } from './stores/useWalletStore'; import HomeView, { type HomeAgentMode, @@ -106,10 +97,6 @@ const seedManifest = createGameCreationAppManifest( ); const defaultProjectPath = '/tmp/genarrative-ai-game-draft'; -type RechargeContent = Omit< - ProfileRechargeCenterResponse, - 'walletBalance' | 'mudPointBalance' ->; const RECENT_WORKSPACES_STORAGE_KEY = 'genarrative-ai-game-creator.recent-workspaces.v1'; const AGENT_RUN_HISTORY_MAX_COUNT = 100; @@ -5377,16 +5364,23 @@ export function WorkspaceLauncher({ const [walletLedgerError, setWalletLedgerError] = useState( null, ); - const [rechargeOpen, setRechargeOpen] = useState(false); - const [rechargeContent, setRechargeContent] = - useState(null); - const [rechargeLoading, setRechargeLoading] = useState(false); - const [rechargeError, setRechargeError] = useState(null); - const [submittingRechargeProductId, setSubmittingRechargeProductId] = - useState(null); - const [nativeRechargePayment, setNativeRechargePayment] = - useState(null); - const rechargeLifecycleRef = useRef(0); + const { + buy: buyRechargeProduct, + close: closeRecharge, + closeNativePayment: closeNativeRechargePayment, + confirmNativePayment: confirmNativeRechargePayment, + content: rechargeContent, + error: rechargeError, + isLoading: rechargeLoading, + isOpen: rechargeOpen, + load: loadRechargeCenter, + nativePayment: nativeRechargePayment, + open: openRecharge, + submittingProductId: submittingRechargeProductId, + } = useRechargeController({ + applyWalletBalanceSnapshot, + onWalletBalanceMayHaveChanged, + }); const rechargeModalCenter = rechargeContent && mudPointBalance ? { @@ -6213,140 +6207,6 @@ export function WorkspaceLauncher({ void loadWalletLedger(); } - function applyRechargeContent(center: ProfileRechargeCenterResponse) { - const { walletBalance, mudPointBalance, ...content } = center; - void walletBalance; - if (mudPointBalance) { - applyWalletBalanceSnapshot(mudPointBalance); - } - setRechargeContent(content); - } - - async function loadRechargeCenter() { - const rechargeLifecycle = rechargeLifecycleRef.current; - setRechargeLoading(true); - setRechargeError(null); - try { - const center = await getClientProfileRechargeCenter(); - if (rechargeLifecycleRef.current !== rechargeLifecycle) { - return; - } - applyRechargeContent(center); - } catch (error) { - if (rechargeLifecycleRef.current === rechargeLifecycle) { - setRechargeError( - error instanceof Error ? error.message : '读取泥点购买信息失败', - ); - } - } finally { - if (rechargeLifecycleRef.current === rechargeLifecycle) { - setRechargeLoading(false); - } - } - } - - function openRecharge() { - rechargeLifecycleRef.current += 1; - setRechargeOpen(true); - setRechargeError(null); - void loadRechargeCenter(); - } - - function closeRecharge() { - rechargeLifecycleRef.current += 1; - setRechargeOpen(false); - setNativeRechargePayment(null); - setSubmittingRechargeProductId(null); - } - - async function buyRechargeProduct(product: ProfileRechargeProduct) { - if (submittingRechargeProductId) { - return; - } - const rechargeLifecycle = rechargeLifecycleRef.current; - setSubmittingRechargeProductId(product.productId); - setRechargeError(null); - try { - const response = await createClientProfileRechargeOrder( - product.productId, - ); - if (rechargeLifecycleRef.current !== rechargeLifecycle) { - return; - } - applyRechargeContent(response.center); - const nativePayment = response.wechatNativePayment; - const codeUrl = nativePayment?.codeUrl?.trim(); - const expiresAt = nativePayment?.expiresAt?.trim(); - if (!nativePayment || !codeUrl || !expiresAt) { - throw new Error('微信 Native 支付链接生成失败'); - } - setNativeRechargePayment({ - orderId: response.order.orderId, - productTitle: response.order.productTitle, - amountCents: response.order.amountCents, - codeUrl, - expiresAt, - isConfirming: false, - }); - } catch (error) { - if (rechargeLifecycleRef.current === rechargeLifecycle) { - setRechargeError(error instanceof Error ? error.message : '充值失败'); - } - } finally { - if (rechargeLifecycleRef.current === rechargeLifecycle) { - setSubmittingRechargeProductId(null); - } - } - } - - async function confirmNativeRechargePayment() { - if (!nativeRechargePayment || nativeRechargePayment.isConfirming) { - return; - } - const rechargeLifecycle = rechargeLifecycleRef.current; - const orderId = nativeRechargePayment.orderId; - setNativeRechargePayment((current) => - current?.orderId === orderId - ? { ...current, isConfirming: true, confirmMessage: undefined } - : current, - ); - try { - const response = await confirmClientWechatProfileRechargeOrder(orderId); - if (rechargeLifecycleRef.current !== rechargeLifecycle) { - return; - } - applyRechargeContent(response.center); - if (response.order.status === 'paid') { - setNativeRechargePayment(null); - void onWalletBalanceMayHaveChanged(); - return; - } - const confirmMessage = - response.order.status === 'pending' || - (response.order.status === 'expired' && - !response.order.expirationCheckedAt) - ? '暂未确认到账,请确认付款完成后再点一次。' - : '订单未支付成功,请关闭二维码后重新下单。'; - setNativeRechargePayment((current) => - current?.orderId === orderId - ? { ...current, isConfirming: false, confirmMessage } - : current, - ); - } catch { - if (rechargeLifecycleRef.current === rechargeLifecycle) { - setNativeRechargePayment((current) => - current?.orderId === orderId - ? { - ...current, - isConfirming: false, - confirmMessage: '暂时没能确认到账状态,请稍后再试。', - } - : current, - ); - } - } - } - function showLauncherNotice(title: string) { setLauncherNotice({ title, @@ -9446,7 +9306,7 @@ export function WorkspaceLauncher({ onRetry={() => void loadRechargeCenter()} onBuy={(product) => void buyRechargeProduct(product)} onConfirmNativePayment={() => void confirmNativeRechargePayment()} - onCloseNativePayment={() => setNativeRechargePayment(null)} + onCloseNativePayment={closeNativeRechargePayment} /> ) : null} {walletLedgerOpen ? ( diff --git a/apps/ai-game-creator-shell/src/hooks/useRechargeController.ts b/apps/ai-game-creator-shell/src/hooks/useRechargeController.ts new file mode 100644 index 000000000..571b3c9cb --- /dev/null +++ b/apps/ai-game-creator-shell/src/hooks/useRechargeController.ts @@ -0,0 +1,155 @@ +import { useCallback, useRef, useState } from 'react'; + +import type { + ProfileMudPointBalance, + ProfileRechargeCenterResponse, + ProfileRechargeProduct, +} from '../../../../packages/shared/src'; +import { useWechatNativeRechargeController } from '../../../../packages/shared/src/components/PlatformProfileRechargeModal/useWechatNativeRechargeController'; +import { + confirmClientWechatProfileRechargeOrder, + createClientProfileRechargeOrder, + getClientProfileRechargeCenter, + watchClientWechatProfileRechargeOrder, +} from '../services/clientApi'; + +export type RechargeContent = Omit< + ProfileRechargeCenterResponse, + 'walletBalance' | 'mudPointBalance' +>; + +type UseRechargeControllerArgs = { + applyWalletBalanceSnapshot: (balance: ProfileMudPointBalance) => void; + onWalletBalanceMayHaveChanged: () => Promise; +}; + +export function useRechargeController({ + applyWalletBalanceSnapshot, + onWalletBalanceMayHaveChanged, +}: UseRechargeControllerArgs) { + const [isOpen, setIsOpen] = useState(false); + const [content, setContent] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [submittingProductId, setSubmittingProductId] = useState( + null, + ); + const lifecycleRef = useRef(0); + + const applyCenter = useCallback( + (center: ProfileRechargeCenterResponse) => { + const { walletBalance, mudPointBalance, ...nextContent } = center; + void walletBalance; + setContent(nextContent); + if (mudPointBalance) { + applyWalletBalanceSnapshot(mudPointBalance); + } + }, + [applyWalletBalanceSnapshot], + ); + + const { + beginNativePayment, + closeNativePayment, + confirmNativePayment, + nativePayment, + paymentResult, + resetNativePayment, + setPaymentResult, + } = useWechatNativeRechargeController({ + confirmOrder: confirmClientWechatProfileRechargeOrder, + watchOrder: watchClientWechatProfileRechargeOrder, + onCenterReceived: applyCenter, + onPaid: onWalletBalanceMayHaveChanged, + }); + + const load = useCallback(async () => { + const lifecycle = lifecycleRef.current; + setIsLoading(true); + setError(null); + try { + const center = await getClientProfileRechargeCenter(); + if (lifecycleRef.current === lifecycle) { + applyCenter(center); + } + } catch (loadError) { + if (lifecycleRef.current === lifecycle) { + setError( + loadError instanceof Error + ? loadError.message + : '读取泥点购买信息失败', + ); + } + } finally { + if (lifecycleRef.current === lifecycle) { + setIsLoading(false); + } + } + }, [applyCenter]); + + const open = useCallback(() => { + lifecycleRef.current += 1; + resetNativePayment(); + setIsOpen(true); + setError(null); + void load(); + }, [load, resetNativePayment]); + + const close = useCallback(() => { + lifecycleRef.current += 1; + setIsOpen(false); + setSubmittingProductId(null); + resetNativePayment(); + }, [resetNativePayment]); + + const buy = useCallback( + async (product: ProfileRechargeProduct) => { + if (submittingProductId) { + return; + } + const lifecycle = lifecycleRef.current; + setSubmittingProductId(product.productId); + setError(null); + setPaymentResult(null); + try { + const response = await createClientProfileRechargeOrder( + product.productId, + ); + if (lifecycleRef.current !== lifecycle) { + return; + } + applyCenter(response.center); + beginNativePayment(response); + } catch (buyError) { + if (lifecycleRef.current === lifecycle) { + setError(buyError instanceof Error ? buyError.message : '充值失败'); + } + } finally { + if (lifecycleRef.current === lifecycle) { + setSubmittingProductId(null); + } + } + }, + [applyCenter, beginNativePayment, setPaymentResult, submittingProductId], + ); + + const paymentResultError = + paymentResult && paymentResult.kind !== 'success' + ? paymentResult.message + : null; + + return { + buy, + close, + closeNativePayment, + confirmNativePayment, + content, + error: error ?? paymentResultError, + isLoading, + isOpen, + load, + nativePayment, + open, + submittingProductId, + }; +} diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index c16b24437..27538775e 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -97,6 +97,22 @@ export async function requestClientApi( init: RequestInit, fallbackMessage: string, options: { skipAuth?: boolean } = {}, +) { + const response = await requestClientApiResponse( + url, + init, + fallbackMessage, + options, + ); + const text = await response.text(); + return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); +} + +async function requestClientApiResponse( + url: string, + init: RequestInit, + fallbackMessage: string, + options: { skipAuth?: boolean } = {}, ) { const headers = new Headers(init.headers); headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); @@ -125,8 +141,7 @@ export async function requestClientApi( { status: response.status }, ); } - const text = await response.text(); - return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); + return response; } export function getClientProfileDashboard() { @@ -168,6 +183,51 @@ export function confirmClientWechatProfileRechargeOrder(orderId: string) { ); } +export async function watchClientWechatProfileRechargeOrder( + orderId: string, + options: { signal: AbortSignal }, +) { + const response = await requestClientApiResponse( + `/api/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/events`, + { + method: 'GET', + headers: { Accept: 'text/event-stream' }, + signal: options.signal, + }, + '订阅充值订单状态失败', + ); + const blocks = (await response.text()).split(/\r?\n\r?\n/u); + let latest: ConfirmWechatProfileRechargeOrderResponse | null = null; + for (const block of blocks) { + let eventName = 'message'; + const dataLines: string[] = []; + for (const line of block.split(/\r?\n/u)) { + if (line.startsWith('event:')) { + eventName = line.slice('event:'.length).trim(); + } else if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trimStart()); + } + } + if (dataLines.length === 0) { + continue; + } + const parsed = JSON.parse(dataLines.join('\n')) as Record; + if (eventName === 'order' && parsed.order && parsed.center) { + latest = parsed as ConfirmWechatProfileRechargeOrderResponse; + } else if (eventName === 'error') { + throw new Error( + typeof parsed.message === 'string' && parsed.message.trim() + ? parsed.message.trim() + : '订阅充值订单状态失败', + ); + } + } + if (!latest) { + throw new Error('充值订单状态流返回不完整'); + } + return latest; +} + export function getClientProfileWalletLedger() { return requestClientApi( '/api/profile/wallet-ledger', diff --git a/apps/ai-game-creator-shell/tests/clientApiRecharge.test.ts b/apps/ai-game-creator-shell/tests/clientApiRecharge.test.ts new file mode 100644 index 000000000..c2fb7bbd4 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientApiRecharge.test.ts @@ -0,0 +1,77 @@ +/* @vitest-environment jsdom */ + +import { afterEach, expect, it, vi } from 'vitest'; + +import { watchClientWechatProfileRechargeOrder } from '../src/services/clientApi'; + +const storedValues = new Map(); +Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + clear: () => storedValues.clear(), + getItem: (key: string) => storedValues.get(key) ?? null, + removeItem: (key: string) => storedValues.delete(key), + setItem: (key: string, value: string) => storedValues.set(key, value), + }, +}); + +afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); +}); + +it('reads the latest authoritative order snapshot from the recharge SSE stream', async () => { + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'access-token', + ); + const order = { + orderId: 'order-native-1', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'paid', + paymentChannel: 'wechat_native', + paidAt: '2026-07-17T00:01:00Z', + providerTransactionId: 'transaction-1', + createdAt: '2026-07-17T00:00:00Z', + pointsDelta: 60, + membershipExpiresAt: null, + }; + const center = { walletBalance: 180 }; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response( + [ + 'event: order', + `data: ${JSON.stringify({ order, center })}`, + '', + 'event: done', + `data: ${JSON.stringify({ orderId: order.orderId, status: 'paid' })}`, + '', + ].join('\n'), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ), + ); + const abortController = new AbortController(); + + await expect( + watchClientWechatProfileRechargeOrder(order.orderId, { + signal: abortController.signal, + }), + ).resolves.toEqual({ order, center }); + expect(fetchSpy).toHaveBeenCalledWith( + `/api/profile/recharge/orders/${order.orderId}/wechat/events`, + expect.objectContaining({ + method: 'GET', + signal: abortController.signal, + headers: expect.any(Headers), + }), + ); + const request = fetchSpy.mock.calls[0]?.[1]; + expect(new Headers(request?.headers).get('Authorization')).toBe( + 'Bearer access-token', + ); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7b06d3f15..47bdad4b1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4813,3 +4813,10 @@ - 就绪边界:HTTP `/readyz` 同时验证调用池握手和缓存读连接;required subscription 失败必须不就绪。非 HTTP worker / controller 不创建缓存读连接,继续使用 1 条调用连接和各自的队列窄订阅。 - 运维口径:`GENARRATIVE_SPACETIME_POOL_SIZE=8` 表示 8 条调用连接,HTTP 基础拓扑另加 1 条缓存读连接;外部生成和充值过期监听的独立窄订阅不计入该值。读模型行缓存从 8 份降为 1 份,但 SDK 空 table metadata、8 条调用 socket 和 runner 仍存在,不承诺总 RSS 等比例降为八分之一。 - 验证方式:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`;发布后在 8 个调用槽暖机后对比 api-server cgroup memory / PSS,并确认 `/readyz` 与代表性 gallery、公开详情、创作入口和用户标签读取正常。 + +## 2026-07-17 主站与 AI 游戏创作客户端复用微信 Native 充值生命周期 + +- 背景:主站个人中心已具备微信 Native 二维码确认重试和 SSE 自动到账监听,AI 游戏创作客户端又在大型 `App.tsx` 中维护一套简化充值状态,关闭、迟到响应和终态语义容易继续分叉。 +- 决策:`packages/shared` 的充值组件目录新增宿主无关的 `useWechatNativeRechargeController`,通过注入确认、监听和余额快照回调统一管理二维码校验、手动确认重试、SSE 监听、终态映射和 lifecycle 隔离。主站只把 Native 分支委托给共享 controller,H5、JSAPI、小程序、登录恢复、任务和邀请码仍留在原 controller;AI 游戏创作客户端通过本地 `useRechargeController` 托管弹窗加载与固定 `wechat_native` 下单,`App.tsx` 只负责视图接线。 +- 余额边界:AI 游戏创作客户端的 `useWalletStore.mudPointBalance` 仍是唯一余额真相;充值响应只把后端完整快照写入 store,支付成功后触发完整刷新,不在客户端本地推算或增减泥点。 +- 验证方式:共享 hook Vitest、AI 游戏创作客户端充值与 Wallet Store 定向测试、主站充值渠道定向测试、两个 TypeScript 边界、`npm run check:encoding` 和 `git diff --check`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 952539f49..7731c320d 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -88,7 +88,7 @@ V1.18 开发窗口把模式扩展为 `执行 / 聊天 / 目标`,Goal 创建和 以下能力清单保留 Runtime V1 的演进记录;其中“App 进程内 tokio task”“跨进程同项目写入不作为支持目标”和“恢复到当前 App 进程”的旧描述均已由 V1.1 替代。当前边界是 App / CLI 只落账并唤醒同一发布二进制的独立 Runner,append-only JSONL 使用进程内锁加 OS 文件锁,恢复继续由 Runner 接管同一 run / session。 -客户端泥点余额统一由 `apps/ai-game-creator-shell/src/stores/useWalletStore.ts` Zustand store 持有,唯一余额真相是 `mudPointBalance`。`ProfileDashboardSummary.walletBalance` 不作为余额来源;所有可能改变余额的动作统一调用 `onWalletBalanceMayHaveChanged()` 从 recharge-center 完整刷新,不在客户端本地增减余额。账单请求、充值产品内容、下单 / 支付确认和弹窗状态留在使用它们的组件,侧栏账户菜单直接订阅该 store,不通过布局 props 传递余额。 +客户端泥点余额统一由 `apps/ai-game-creator-shell/src/stores/useWalletStore.ts` Zustand store 持有,唯一余额真相是 `mudPointBalance`。`ProfileDashboardSummary.walletBalance` 不作为余额来源;充值中心、下单和支付确认返回的后端余额快照统一写入该 store,支付成功后再调用 `onWalletBalanceMayHaveChanged()` 完整刷新,不在客户端本地增减余额。账单请求状态留在使用它的组件;充值弹窗加载与下单状态收口到客户端 `useRechargeController`,微信 Native 二维码、确认重试、SSE 到账监听和迟到响应隔离复用 `packages/shared` 的 `useWechatNativeRechargeController`。侧栏账户菜单直接订阅钱包 store,不通过布局 props 传递余额。 Agent Runtime 负责: @@ -530,7 +530,7 @@ game-project/ - 聊天输入 `/publish` 只使用主窗口当前已加载的 manifest、最近 run trace、预览状态、资产来源和最近命令摘要,在聊天里生成发布准备清单,列出原型通过状态、预览、任务、资产、音频、包装说明和试玩包导出状态,并提供 `/run`、`/trace`、`/agent-resume ` 或 `/export` 草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件、不新增普通用户面板,真正导出仍由用户发送 `/export` 并走确认流。 - 开发窗口可从 Agent 状态列表进入单个专业 Agent 对话并管理其 Session;正式用户项目开发页只读展示专业 Agent 协作状态,不提供单 Agent 对话入口、Session 控件或工具台。 - v1 普通用户登录后直接进入单窗口客户端首页;同一窗口中切换首页、项目组、指南 / 反馈和项目开发页。项目组页管理最近项目、打开项目、新建项目和显示目录;打开项目只切换到项目开发页,不调用 `open_game_creator_workspace_window` 打开第二窗口。旧 Tauri 窗口 command 只保留兼容,不进入用户主流程。 -- 2026-07-16 补充,2026-07-17 扩展:普通用户窗口顶部账户资产统一复用 `PlatformMudPointWalletEntry`;首屏余额来自 `/api/profile/dashboard`,展开时按需读取 `/api/profile/recharge-center` 中的泥点拆分。顶部资产“充值”与侧栏账户菜单“充值泥点”共用 `PlatformProfileRechargeModal`,桌面壳固定使用 `wechat_native` 下单并在弹窗内完成扫码与到账确认,成功后同步刷新顶部余额和泥点拆分。“使用详情”统一打开 `packages/shared` 中的 `PlatformProfileWalletLedgerModal`,由各宿主分别维护打开、加载、失败重试状态并读取 `/api/profile/wallet-ledger`,共享组件只承接账单来源文案、金额与日期展示及 loading / empty / error 视图,不发请求、不持有账户事实。AI 游戏创作壳的 Tailwind 入口必须显式扫描 `packages/shared/src/components`,避免共享组件的 utility 样式在构建时被遗漏。 +- 2026-07-16 补充,2026-07-17 扩展:普通用户窗口顶部账户资产统一复用 `PlatformMudPointWalletEntry`;余额统一来自 `/api/profile/recharge-center` 的 `mudPointBalance`,不再把 `/api/profile/dashboard` 的兼容总额作为客户端余额真相。顶部资产“充值”与侧栏账户菜单“充值泥点”共用 `PlatformProfileRechargeModal`,桌面壳固定使用 `wechat_native` 下单并在弹窗内完成扫码与到账确认;客户端充值 controller 与主站共同复用共享 Native 支付 controller,统一处理二维码校验、手动确认重试、SSE 自动到账监听和关闭后的迟到响应,成功后同步刷新顶部余额和泥点拆分。“使用详情”统一打开 `packages/shared` 中的 `PlatformProfileWalletLedgerModal`,由各宿主分别维护打开、加载、失败重试状态并读取 `/api/profile/wallet-ledger`,共享组件只承接账单来源文案、金额与日期展示及 loading / empty / error 视图,不发请求、不持有账户事实。AI 游戏创作壳的 Tailwind 入口必须显式扫描 `packages/shared/src/components`,避免共享组件的 utility 样式在构建时被遗漏。 - 主窗口可通过系统文件管理器显示当前项目目录,也可在聊天输入 `/open-project` 走同一只读打开动作;该操作只打开本地目录,不初始化项目、不写项目文件、不切换工作区。主窗口头部显示最近 `.agent/run.latest.json` 的 run 状态摘要和当前项目预览状态,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。 - 首页、项目组页和项目开发页共用单窗口壳的全局运行时配置弹窗,读写 Tauri 应用配置目录中的 `game-creator.config.json`;正式 Supervisor 项目页缺配置时只显示错误,不自动打开该弹窗。API Key 仍不进入本地项目、trace、manifest 或聊天记录。 - 首页发送和项目组新建都通过 Tauri 原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。 diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/useWechatNativeRechargeController.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/useWechatNativeRechargeController.test.tsx new file mode 100644 index 000000000..ba3862746 --- /dev/null +++ b/packages/shared/src/components/PlatformProfileRechargeModal/useWechatNativeRechargeController.test.tsx @@ -0,0 +1,218 @@ +/* @vitest-environment jsdom */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + ConfirmWechatProfileRechargeOrderResponse, + CreateProfileRechargeOrderResponse, + ProfileRechargeOrder, +} from '../../contracts/runtime'; +import { + buildRechargePaymentResult, + isWechatRechargeOrderTerminal, + useWechatNativeRechargeController, +} from './useWechatNativeRechargeController'; + +function buildOrder( + overrides: Partial = {}, +): ProfileRechargeOrder { + return { + orderId: 'order-native-1', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending', + paymentChannel: 'wechat_native', + paidAt: null, + providerTransactionId: null, + createdAt: '2026-07-17T00:00:00Z', + pointsDelta: 0, + membershipExpiresAt: null, + ...overrides, + }; +} + +function buildCenter(walletBalance = 120) { + return { + walletBalance, + mudPointBalance: { + totalPoints: walletBalance, + permanentPoints: walletBalance, + limitedPoints: 0, + limitedExpiresAt: null, + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-07-18T00:00:00Z', + }, + membership: { + status: 'inactive' as const, + tier: 'normal' as const, + startedAt: null, + expiresAt: null, + updatedAt: null, + cycleStartedAt: null, + cycleResetsAt: null, + cycleGrantedPoints: 0, + cycleRemainingPoints: 0, + cyclePeriodDays: 0, + }, + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }; +} + +function buildCreateResponse(): CreateProfileRechargeOrderResponse { + return { + order: buildOrder(), + center: buildCenter(), + wechatNativePayment: { + codeUrl: 'weixin://pay.weixin.qq.com/native-test', + expiresAt: '2099-01-01T00:05:00Z', + }, + }; +} + +describe('useWechatNativeRechargeController', () => { + it('classifies pending expiration separately from terminal expiration', () => { + expect( + isWechatRechargeOrderTerminal({ + status: 'expired', + expirationCheckedAt: null, + }), + ).toBe(false); + expect( + buildRechargePaymentResult({ + status: 'expired', + expirationCheckedAt: '2026-07-17T00:05:00Z', + }), + ).toMatchObject({ kind: 'expired', title: '支付已过期' }); + }); + + it('rejects an incomplete native payment response', () => { + const { result } = renderHook(() => + useWechatNativeRechargeController({ + confirmOrder: vi.fn(), + onCenterReceived: vi.fn(), + }), + ); + const response = buildCreateResponse(); + response.wechatNativePayment = { codeUrl: ' ', expiresAt: '2099-01-01' }; + + expect(() => { + act(() => result.current.beginNativePayment(response)); + }).toThrow('微信 Native 支付链接生成失败'); + }); + + it('settles a watched order once and reports the authoritative center', async () => { + const paidResponse: ConfirmWechatProfileRechargeOrderResponse = { + order: buildOrder({ + status: 'paid', + paidAt: '2026-07-17T00:01:00Z', + pointsDelta: 60, + }), + center: buildCenter(180), + }; + const watchOrder = vi.fn().mockResolvedValue(paidResponse); + const onCenterReceived = vi.fn(); + const onPaid = vi.fn(); + const onTerminal = vi.fn(); + const { result } = renderHook(() => + useWechatNativeRechargeController({ + confirmOrder: vi.fn(), + watchOrder, + onCenterReceived, + onPaid, + onTerminal, + }), + ); + + act(() => result.current.beginNativePayment(buildCreateResponse())); + + await waitFor(() => expect(result.current.nativePayment).toBeNull()); + expect(result.current.paymentResult?.kind).toBe('success'); + expect(onCenterReceived).toHaveBeenCalledWith(paidResponse.center); + expect(onPaid).toHaveBeenCalledTimes(1); + expect(onTerminal).toHaveBeenCalledTimes(1); + }); + + it('retries an explicit confirmation before settling a paid order', async () => { + vi.useFakeTimers(); + const confirmOrder = vi + .fn() + .mockResolvedValueOnce({ + order: buildOrder(), + center: buildCenter(), + }) + .mockResolvedValueOnce({ + order: buildOrder({ status: 'paid', pointsDelta: 60 }), + center: buildCenter(180), + }); + const onPaid = vi.fn(); + const { result } = renderHook(() => + useWechatNativeRechargeController({ + confirmOrder, + onCenterReceived: vi.fn(), + onPaid, + }), + ); + act(() => result.current.beginNativePayment(buildCreateResponse())); + + let confirmation: Promise | undefined; + act(() => { + confirmation = result.current.confirmNativePayment(); + }); + await act(async () => { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(800); + await confirmation; + }); + + expect(confirmOrder).toHaveBeenCalledTimes(2); + expect(result.current.paymentResult?.kind).toBe('success'); + expect(onPaid).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it('ignores a watched response after the native lifecycle is reset', async () => { + let resolveWatch: + | ((response: ConfirmWechatProfileRechargeOrderResponse) => void) + | undefined; + const watchOrder = vi.fn( + () => + new Promise((resolve) => { + resolveWatch = resolve; + }), + ); + const onCenterReceived = vi.fn(); + const onPaid = vi.fn(); + const { result } = renderHook(() => + useWechatNativeRechargeController({ + confirmOrder: vi.fn(), + watchOrder, + onCenterReceived, + onPaid, + }), + ); + + act(() => result.current.beginNativePayment(buildCreateResponse())); + await waitFor(() => expect(watchOrder).toHaveBeenCalledTimes(1)); + act(() => result.current.resetNativePayment()); + await act(async () => { + resolveWatch?.({ + order: buildOrder({ status: 'paid' }), + center: buildCenter(180), + }); + await Promise.resolve(); + }); + + expect(result.current.nativePayment).toBeNull(); + expect(result.current.paymentResult).toBeNull(); + expect(onCenterReceived).not.toHaveBeenCalled(); + expect(onPaid).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/useWechatNativeRechargeController.ts b/packages/shared/src/components/PlatformProfileRechargeModal/useWechatNativeRechargeController.ts new file mode 100644 index 000000000..cb175909a --- /dev/null +++ b/packages/shared/src/components/PlatformProfileRechargeModal/useWechatNativeRechargeController.ts @@ -0,0 +1,307 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { + ConfirmWechatProfileRechargeOrderResponse, + CreateProfileRechargeOrderResponse, + ProfileRechargeOrder, +} from '../../contracts/runtime'; +import type { PlatformProfileRechargeNativePaymentState } from './index'; + +const WECHAT_NATIVE_CONFIRM_RETRY_DELAYS_MS = [800, 1600] as const; +const WECHAT_NATIVE_WATCH_RETRY_DELAY_MS = 1000; + +export type RechargePaymentResult = { + kind: 'success' | 'pending' | 'cancel' | 'failed' | 'expired'; + title: string; + message: string; +}; + +export type UseWechatNativeRechargeControllerArgs = { + confirmOrder: ( + orderId: string, + ) => Promise; + watchOrder?: ( + orderId: string, + options: { signal: AbortSignal }, + ) => Promise; + onCenterReceived: ( + response: ConfirmWechatProfileRechargeOrderResponse['center'], + ) => void; + onPaid?: () => void | Promise; + onTerminal?: (response: ConfirmWechatProfileRechargeOrderResponse) => void; +}; + +export function isWechatRechargeOrderTerminal( + order: Pick, +) { + return !( + order.status === 'pending' || + (order.status === 'expired' && !order.expirationCheckedAt) + ); +} + +export function buildRechargePaymentResult( + order: Pick, +): RechargePaymentResult { + switch (order.status) { + case 'paid': + return { + kind: 'success', + title: '支付成功', + message: '已到账,泥点余额已刷新。', + }; + case 'expired': + return order.expirationCheckedAt + ? { + kind: 'expired', + title: '支付已过期', + message: '订单已超过支付时限,本次没有入账。', + } + : { + kind: 'pending', + title: '支付处理中', + message: '正在等待到账状态确认,请稍后查看泥点余额。', + }; + case 'closed': + return { + kind: 'cancel', + title: '支付未完成', + message: '本次没有扣款,泥点余额未发生变化。', + }; + case 'failed': + case 'refunded': + return { + kind: 'failed', + title: '支付未完成', + message: '微信支付没有完成,本次不会入账。', + }; + case 'pending': + default: + return { + kind: 'pending', + title: '支付处理中', + message: '正在等待到账状态确认,请稍后查看泥点余额。', + }; + } +} + +function waitForConfirmation(delayMs: number) { + return new Promise((resolve) => { + window.setTimeout(resolve, delayMs); + }); +} + +export function useWechatNativeRechargeController({ + confirmOrder, + watchOrder, + onCenterReceived, + onPaid, + onTerminal, +}: UseWechatNativeRechargeControllerArgs) { + const [nativePayment, setNativePayment] = + useState(null); + const [paymentResult, setPaymentResult] = + useState(null); + const activeOrderIdRef = useRef(null); + const lifecycleRef = useRef(0); + const settledOrderIdsRef = useRef(new Set()); + + const applyTerminalResponse = useCallback( + (response: ConfirmWechatProfileRechargeOrderResponse) => { + const orderId = response.order.orderId; + if ( + activeOrderIdRef.current !== orderId || + settledOrderIdsRef.current.has(orderId) + ) { + return false; + } + + onCenterReceived(response.center); + if (!isWechatRechargeOrderTerminal(response.order)) { + return false; + } + + settledOrderIdsRef.current.add(orderId); + activeOrderIdRef.current = null; + setNativePayment((current) => + current?.orderId === orderId ? null : current, + ); + const result = buildRechargePaymentResult(response.order); + setPaymentResult(result); + onTerminal?.(response); + if (result.kind === 'success') { + void onPaid?.(); + } + return true; + }, + [onCenterReceived, onPaid, onTerminal], + ); + + const beginNativePayment = useCallback( + (response: CreateProfileRechargeOrderResponse) => { + const payment = response.wechatNativePayment; + const codeUrl = payment?.codeUrl?.trim(); + const expiresAt = payment?.expiresAt?.trim(); + if (!payment || !codeUrl || !expiresAt) { + throw new Error('微信 Native 支付链接生成失败'); + } + + lifecycleRef.current += 1; + settledOrderIdsRef.current.delete(response.order.orderId); + activeOrderIdRef.current = response.order.orderId; + setPaymentResult(null); + setNativePayment({ + orderId: response.order.orderId, + productTitle: response.order.productTitle, + amountCents: response.order.amountCents, + codeUrl, + expiresAt, + isConfirming: false, + }); + }, + [], + ); + + const closeNativePayment = useCallback(() => { + setNativePayment((current) => { + if (current?.isConfirming) { + return current; + } + lifecycleRef.current += 1; + activeOrderIdRef.current = null; + return null; + }); + }, []); + + const discardNativePayment = useCallback(() => { + lifecycleRef.current += 1; + activeOrderIdRef.current = null; + setNativePayment(null); + }, []); + + const resetNativePayment = useCallback(() => { + discardNativePayment(); + setPaymentResult(null); + }, [discardNativePayment]); + + const confirmNativePayment = useCallback(async () => { + const payment = nativePayment; + if (!payment || payment.isConfirming) { + return; + } + + const orderId = payment.orderId; + const lifecycle = lifecycleRef.current; + setNativePayment((current) => + current?.orderId === orderId + ? { ...current, isConfirming: true, confirmMessage: undefined } + : current, + ); + + try { + let response = await confirmOrder(orderId); + for (const delayMs of WECHAT_NATIVE_CONFIRM_RETRY_DELAYS_MS) { + if ( + lifecycleRef.current !== lifecycle || + activeOrderIdRef.current !== orderId || + isWechatRechargeOrderTerminal(response.order) + ) { + break; + } + await waitForConfirmation(delayMs); + response = await confirmOrder(orderId); + } + + if ( + lifecycleRef.current !== lifecycle || + activeOrderIdRef.current !== orderId + ) { + return; + } + if (!applyTerminalResponse(response)) { + setNativePayment((current) => + current?.orderId === orderId + ? { + ...current, + isConfirming: false, + confirmMessage: '暂未确认到账,请确认付款完成后再点一次。', + } + : current, + ); + } + } catch { + if ( + lifecycleRef.current === lifecycle && + activeOrderIdRef.current === orderId + ) { + setNativePayment((current) => + current?.orderId === orderId + ? { + ...current, + isConfirming: false, + confirmMessage: '暂时没能确认到账状态,请稍后再试。', + } + : current, + ); + } + } + }, [applyTerminalResponse, confirmOrder, nativePayment]); + + useEffect(() => { + const orderId = nativePayment?.orderId; + const expiresAtMs = Date.parse(nativePayment?.expiresAt ?? ''); + if (!watchOrder || !orderId || !Number.isFinite(expiresAtMs)) { + return undefined; + } + + const lifecycle = lifecycleRef.current; + const abortController = new AbortController(); + let cancelled = false; + const watchUntilSettled = async () => { + while (!cancelled && Date.now() < expiresAtMs) { + try { + const response = await watchOrder(orderId, { + signal: abortController.signal, + }); + if ( + cancelled || + lifecycleRef.current !== lifecycle || + activeOrderIdRef.current !== orderId + ) { + return; + } + if (applyTerminalResponse(response)) { + return; + } + } catch { + if (cancelled || abortController.signal.aborted) { + return; + } + } + await waitForConfirmation(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS); + } + }; + + void watchUntilSettled(); + return () => { + cancelled = true; + abortController.abort(); + }; + }, [ + applyTerminalResponse, + nativePayment?.expiresAt, + nativePayment?.orderId, + watchOrder, + ]); + + return { + beginNativePayment, + closeNativePayment, + confirmNativePayment, + discardNativePayment, + nativePayment, + paymentResult, + resetNativePayment, + setPaymentResult, + }; +} diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 67b5172d4..cee0bad65 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -1,6 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { PlatformProfileRechargeNativePaymentState } from '../../../packages/shared/src/components/PlatformProfileRechargeModal'; +import { + buildRechargePaymentResult, + useWechatNativeRechargeController, +} from '../../../packages/shared/src/components/PlatformProfileRechargeModal/useWechatNativeRechargeController'; +export type { RechargePaymentResult } from '../../../packages/shared/src/components/PlatformProfileRechargeModal/useWechatNativeRechargeController'; import { type ConfirmWechatProfileRechargeOrderResponse, type ProfileRechargeCenterResponse, @@ -51,8 +55,6 @@ const PROFILE_TASK_DAY_MS = 24 * 60 * 60 * 1000; const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000; const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000; const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const; -const WECHAT_NATIVE_CONFIRM_RETRY_DELAYS_MS = [800, 1600] as const; -const WECHAT_NATIVE_WATCH_RETRY_DELAY_MS = 1000; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250; const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000; @@ -68,26 +70,10 @@ type WechatPayResult = { errorMessage: string | null; }; -type RechargePaymentResultKind = - | 'success' - | 'pending' - | 'cancel' - | 'failed' - | 'expired'; - -export type RechargePaymentResult = { - kind: RechargePaymentResultKind; - title: string; - message: string; -}; - export type WechatRechargeOrderConfirmationState = { orderId: string; }; -export type NativeWechatPaymentState = - PlatformProfileRechargeNativePaymentState; - function isWechatJsapiMissingIdentityError(error: unknown) { return ( error instanceof Error && @@ -239,72 +225,6 @@ async function confirmWechatRechargeOrderUntilSettled( } } -async function confirmWechatRechargeOrderQuickly( - orderId: string, -): Promise { - let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); - if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { - return latestResponse; - } - - for (const delayMs of WECHAT_NATIVE_CONFIRM_RETRY_DELAYS_MS) { - await waitWechatPayConfirmDelay(delayMs); - - latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); - if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { - return latestResponse; - } - } - - return latestResponse; -} - -function buildRechargePaymentResultForOrder( - order: Pick, -): RechargePaymentResult { - switch (order.status) { - case 'paid': - return { - kind: 'success', - title: '支付成功', - message: '已到账,泥点余额已刷新。', - }; - case 'expired': - if (!order.expirationCheckedAt) { - return { - kind: 'pending', - title: '支付处理中', - message: '正在等待到账状态确认,请稍后查看泥点余额。', - }; - } - return { - kind: 'expired', - title: '支付已过期', - message: '订单已超过支付时限,本次没有入账。', - }; - case 'closed': - return { - kind: 'cancel', - title: '支付未完成', - message: '本次没有扣款,泥点余额未发生变化。', - }; - case 'failed': - case 'refunded': - return { - kind: 'failed', - title: '支付未完成', - message: '微信支付没有完成,本次不会入账。', - }; - case 'pending': - default: - return { - kind: 'pending', - title: '支付处理中', - message: '正在等待到账状态确认,请稍后查看泥点余额。', - }; - } -} - export function usePlatformProfileCenterController({ activeTab, isAuthenticated, @@ -328,14 +248,10 @@ export function usePlatformProfileCenterController({ useState(null); const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false); const [rechargeError, setRechargeError] = useState(null); - const [rechargePaymentResult, setRechargePaymentResult] = - useState(null); const [ wechatRechargeOrderConfirmationState, setWechatRechargeOrderConfirmationState, ] = useState(null); - const [nativeWechatPayment, setNativeWechatPayment] = - useState(null); const [submittingRechargeProductId, setSubmittingRechargeProductId] = useState(null); const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false); @@ -380,6 +296,27 @@ export function usePlatformProfileCenterController({ useCopyFeedback(); const pendingWechatRechargeOrderIdRef = useRef(null); const confirmingWechatRechargeOrderIdRef = useRef(null); + const handleNativeRechargeTerminal = useCallback(() => { + pendingWechatRechargeOrderIdRef.current = null; + confirmingWechatRechargeOrderIdRef.current = null; + setSubmittingRechargeProductId(null); + setWechatRechargeOrderConfirmationState(null); + }, []); + const { + beginNativePayment, + closeNativePayment: closeSharedNativePayment, + confirmNativePayment: confirmNativeWechatPayment, + discardNativePayment, + nativePayment: nativeWechatPayment, + paymentResult: rechargePaymentResult, + setPaymentResult: setRechargePaymentResult, + } = useWechatNativeRechargeController({ + confirmOrder: confirmWechatRpgProfileRechargeOrder, + watchOrder: watchWechatRpgProfileRechargeOrder, + onCenterReceived: setRechargeCenter, + onPaid: onRechargeSuccess, + onTerminal: handleNativeRechargeTerminal, + }); // 中文注释:支持带邀请码 query 的直达场景,登录成功后自动打开兑换面板并复用同一套输入状态。 useEffect(() => { @@ -441,8 +378,8 @@ export function usePlatformProfileCenterController({ pendingWechatRechargeOrderIdRef.current = null; confirmingWechatRechargeOrderIdRef.current = null; setWechatRechargeOrderConfirmationState(null); - setNativeWechatPayment(null); - }, [loadRechargeCenter]); + discardNativePayment(); + }, [discardNativePayment, loadRechargeCenter]); const handleWechatPayResult = useCallback(() => { const payResult = readWechatPayResultFromHash(); @@ -475,7 +412,7 @@ export function usePlatformProfileCenterController({ setRechargePaymentResult(null); void confirmWechatRechargeOrderUntilSettled(orderId) .then((response) => { - const result = buildRechargePaymentResultForOrder(response.order); + const result = buildRechargePaymentResult(response.order); const isPaid = result.kind === 'success'; setRechargeCenter(response.center); pendingWechatRechargeOrderIdRef.current = null; @@ -520,7 +457,7 @@ export function usePlatformProfileCenterController({ clearWechatPayResultHash(); return true; - }, [onRechargeSuccess, refreshRechargeState]); + }, [onRechargeSuccess, refreshRechargeState, setRechargePaymentResult]); const pollWechatPayResultFromHash = useCallback( () => handleWechatPayResult(), @@ -542,7 +479,7 @@ export function usePlatformProfileCenterController({ setRechargePaymentResult(null); void confirmWechatRechargeOrderUntilSettled(orderId) .then((response) => { - const result = buildRechargePaymentResultForOrder(response.order); + const result = buildRechargePaymentResult(response.order); const isPaid = result.kind === 'success'; setRechargeCenter(response.center); pendingWechatRechargeOrderIdRef.current = null; @@ -564,7 +501,7 @@ export function usePlatformProfileCenterController({ }); }); return true; - }, [nativeWechatPayment, onRechargeSuccess]); + }, [nativeWechatPayment, onRechargeSuccess, setRechargePaymentResult]); const openRechargeModal = useCallback(() => { if (!currentUser) { @@ -592,14 +529,12 @@ export function usePlatformProfileCenterController({ }, [openRechargeModal, openRewardCodeModal, showRechargeEntry]); const closeNativeWechatPayment = useCallback(() => { - setNativeWechatPayment((current) => { - if (current?.isConfirming) { - return current; - } - pendingWechatRechargeOrderIdRef.current = null; - return null; - }); - }, []); + if (nativeWechatPayment?.isConfirming) { + return; + } + pendingWechatRechargeOrderIdRef.current = null; + closeSharedNativePayment(); + }, [closeSharedNativePayment, nativeWechatPayment?.isConfirming]); const buyRechargeProduct = useCallback( (product: ProfileRechargeProduct) => { @@ -615,7 +550,7 @@ export function usePlatformProfileCenterController({ setRechargeError(null); setRechargePaymentResult(null); setWechatRechargeOrderConfirmationState(null); - setNativeWechatPayment(null); + discardNativePayment(); void createRpgProfileRechargeOrder(product.productId, paymentChannel) .then(async (response) => { if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) { @@ -648,7 +583,7 @@ export function usePlatformProfileCenterController({ }); void confirmWechatRechargeOrderUntilSettled(response.order.orderId) .then((confirmResponse) => { - const result = buildRechargePaymentResultForOrder( + const result = buildRechargePaymentResult( confirmResponse.order, ); const isPaid = result.kind === 'success'; @@ -686,23 +621,9 @@ export function usePlatformProfileCenterController({ return; } if (paymentChannel === WECHAT_NATIVE_PAYMENT_CHANNEL) { - const wechatNativePayment = response.wechatNativePayment; - const codeUrl = wechatNativePayment?.codeUrl?.trim(); - const expiresAt = wechatNativePayment?.expiresAt?.trim(); - if (!wechatNativePayment || !codeUrl || !expiresAt) { - throw new Error('微信 Native 支付链接生成失败'); - } pendingWechatRechargeOrderIdRef.current = response.order.orderId; setRechargeCenter(response.center); - setNativeWechatPayment({ - ...wechatNativePayment, - codeUrl, - expiresAt, - orderId: response.order.orderId, - productTitle: response.order.productTitle, - amountCents: response.order.amountCents, - isConfirming: false, - }); + beginNativePayment(response); setSubmittingRechargeProductId(null); return; } @@ -711,7 +632,7 @@ export function usePlatformProfileCenterController({ }) .catch((error: unknown) => { pendingWechatRechargeOrderIdRef.current = null; - setNativeWechatPayment(null); + discardNativePayment(); if ( paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL && getHostRuntime().kind === 'wechat_mini_program' && @@ -759,127 +680,15 @@ export function usePlatformProfileCenterController({ setSubmittingRechargeProductId(null); }); }, - [onRechargeSuccess, submittingRechargeProductId], + [ + beginNativePayment, + discardNativePayment, + onRechargeSuccess, + setRechargePaymentResult, + submittingRechargeProductId, + ], ); - const confirmNativeWechatPayment = useCallback(() => { - if (!nativeWechatPayment || nativeWechatPayment.isConfirming) { - return; - } - - setNativeWechatPayment((current) => - current && current.orderId === nativeWechatPayment.orderId - ? { ...current, isConfirming: true, confirmMessage: undefined } - : current, - ); - void confirmWechatRechargeOrderQuickly(nativeWechatPayment.orderId) - .then((response) => { - if ( - pendingWechatRechargeOrderIdRef.current !== - nativeWechatPayment.orderId - ) { - return; - } - const result = buildRechargePaymentResultForOrder(response.order); - const isPaid = result.kind === 'success'; - setRechargeCenter(response.center); - if (result.kind !== 'pending') { - setNativeWechatPayment(null); - pendingWechatRechargeOrderIdRef.current = null; - setRechargePaymentResult(result); - if (isPaid) { - void onRechargeSuccess?.(); - } - } else { - setNativeWechatPayment((current) => - current && current.orderId === nativeWechatPayment.orderId - ? { - ...current, - isConfirming: false, - confirmMessage: '暂未确认到账,请确认付款完成后再点一次。', - } - : current, - ); - } - }) - .catch(() => { - setNativeWechatPayment((current) => - current && current.orderId === nativeWechatPayment.orderId - ? { - ...current, - isConfirming: false, - confirmMessage: '暂时没能确认到账状态,请稍后再试。', - } - : current, - ); - }) - .finally(() => setSubmittingRechargeProductId(null)); - }, [nativeWechatPayment, onRechargeSuccess]); - - useEffect(() => { - const orderId = nativeWechatPayment?.orderId; - const expiresAtMs = Date.parse(nativeWechatPayment?.expiresAt ?? ''); - if (!orderId || !Number.isFinite(expiresAtMs)) { - return undefined; - } - - let cancelled = false; - const abortController = new AbortController(); - const watchUntilSettled = async () => { - while (!cancelled && Date.now() < expiresAtMs) { - try { - const response = await watchWechatRpgProfileRechargeOrder(orderId, { - signal: abortController.signal, - }); - if ( - cancelled || - !response || - pendingWechatRechargeOrderIdRef.current !== orderId - ) { - return; - } - - const result = buildRechargePaymentResultForOrder(response.order); - setRechargeCenter(response.center); - if (result.kind === 'pending') { - await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS); - continue; - } - - pendingWechatRechargeOrderIdRef.current = null; - if (confirmingWechatRechargeOrderIdRef.current === orderId) { - confirmingWechatRechargeOrderIdRef.current = null; - } - setNativeWechatPayment((current) => - current?.orderId === orderId ? null : current, - ); - setSubmittingRechargeProductId(null); - setRechargePaymentResult(result); - if (result.kind === 'success') { - void onRechargeSuccess?.(); - } - return; - } catch { - if (cancelled || abortController.signal.aborted) { - return; - } - } - - await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS); - } - }; - - void watchUntilSettled(); - return () => { - cancelled = true; - abortController.abort(); - }; - }, [ - nativeWechatPayment?.expiresAt, - nativeWechatPayment?.orderId, - onRechargeSuccess, - ]); - // 中文注释:H5 / 小程序支付返回页、页面恢复和 hash 轮询都统一走同一套到账确认逻辑, // 避免页面组件自己感知微信支付细节。 useEffect(() => {