@@ -35,7 +35,10 @@ import {
|
||||
import {
|
||||
PlatformMudPointWalletEntry,
|
||||
} from '../../../packages/shared/src/components/PlatformMudPointWalletEntry';
|
||||
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
|
||||
import {
|
||||
PlatformProfileRechargeModal,
|
||||
type PlatformProfileRechargeNativePaymentState,
|
||||
} from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
|
||||
import type {
|
||||
AuthEntryResponse,
|
||||
@@ -72,6 +75,8 @@ import {
|
||||
selectGameCreationAppReadyTasks,
|
||||
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type {
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileRechargeProduct,
|
||||
ProfileWalletLedgerResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
@@ -79,8 +84,12 @@ import {
|
||||
API_RESPONSE_ENVELOPE_VERSION,
|
||||
unwrapApiResponse,
|
||||
} from '../../../packages/shared/src/http';
|
||||
import { useRechargeController } from './hooks/useRechargeController';
|
||||
import { getClientProfileWalletLedger } from './services/clientApi';
|
||||
import {
|
||||
confirmClientWechatProfileRechargeOrder,
|
||||
createClientProfileRechargeOrder,
|
||||
getClientProfileRechargeCenter,
|
||||
getClientProfileWalletLedger,
|
||||
} from './services/clientApi';
|
||||
import { useWalletStore } from './stores/useWalletStore';
|
||||
import HomeView, {
|
||||
type HomeAgentMode,
|
||||
@@ -97,6 +106,10 @@ 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;
|
||||
@@ -5364,23 +5377,16 @@ export function WorkspaceLauncher({
|
||||
const [walletLedgerError, setWalletLedgerError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
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 [rechargeOpen, setRechargeOpen] = useState(false);
|
||||
const [rechargeContent, setRechargeContent] =
|
||||
useState<RechargeContent | null>(null);
|
||||
const [rechargeLoading, setRechargeLoading] = useState(false);
|
||||
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
||||
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
|
||||
useState<string | null>(null);
|
||||
const [nativeRechargePayment, setNativeRechargePayment] =
|
||||
useState<PlatformProfileRechargeNativePaymentState | null>(null);
|
||||
const rechargeLifecycleRef = useRef(0);
|
||||
const rechargeModalCenter =
|
||||
rechargeContent && mudPointBalance
|
||||
? {
|
||||
@@ -6207,6 +6213,140 @@ 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,
|
||||
@@ -9306,7 +9446,7 @@ export function WorkspaceLauncher({
|
||||
onRetry={() => void loadRechargeCenter()}
|
||||
onBuy={(product) => void buyRechargeProduct(product)}
|
||||
onConfirmNativePayment={() => void confirmNativeRechargePayment()}
|
||||
onCloseNativePayment={closeNativeRechargePayment}
|
||||
onCloseNativePayment={() => setNativeRechargePayment(null)}
|
||||
/>
|
||||
) : null}
|
||||
{walletLedgerOpen ? (
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
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<void>;
|
||||
};
|
||||
|
||||
export function useRechargeController({
|
||||
applyWalletBalanceSnapshot,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
}: UseRechargeControllerArgs) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [content, setContent] = useState<RechargeContent | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submittingProductId, setSubmittingProductId] = useState<string | null>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -97,22 +97,6 @@ export async function requestClientApi<T>(
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: { skipAuth?: boolean } = {},
|
||||
) {
|
||||
const response = await requestClientApiResponse(
|
||||
url,
|
||||
init,
|
||||
fallbackMessage,
|
||||
options,
|
||||
);
|
||||
const text = await response.text();
|
||||
return text ? unwrapApiResponse<T>(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);
|
||||
@@ -141,7 +125,8 @@ async function requestClientApiResponse(
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
return response;
|
||||
const text = await response.text();
|
||||
return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T);
|
||||
}
|
||||
|
||||
export function getClientProfileDashboard() {
|
||||
@@ -183,51 +168,6 @@ 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<string, unknown>;
|
||||
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<ProfileWalletLedgerResponse>(
|
||||
'/api/profile/wallet-ledger',
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import { watchClientWechatProfileRechargeOrder } from '../src/services/clientApi';
|
||||
|
||||
const storedValues = new Map<string, string>();
|
||||
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',
|
||||
);
|
||||
});
|
||||
@@ -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` 不作为余额来源;充值中心、下单和支付确认返回的后端余额快照统一写入该 store,支付成功后再调用 `onWalletBalanceMayHaveChanged()` 完整刷新,不在客户端本地增减余额。账单请求状态留在使用它的组件;充值弹窗加载与下单状态收口到客户端 `useRechargeController`,微信 Native 二维码、确认重试、SSE 到账监听和迟到响应隔离复用 `packages/shared` 的 `useWechatNativeRechargeController`。侧栏账户菜单直接订阅钱包 store,不通过布局 props 传递余额。
|
||||
客户端泥点余额统一由 `apps/ai-game-creator-shell/src/stores/useWalletStore.ts` Zustand store 持有,唯一余额真相是 `mudPointBalance`。`ProfileDashboardSummary.walletBalance` 不作为余额来源;所有可能改变余额的动作统一调用 `onWalletBalanceMayHaveChanged()` 从 recharge-center 完整刷新,不在客户端本地增减余额。账单请求、充值产品内容、下单 / 支付确认和弹窗状态留在使用它们的组件,侧栏账户菜单直接订阅该 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/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 样式在构建时被遗漏。
|
||||
- 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 样式在构建时被遗漏。
|
||||
- 主窗口可通过系统文件管理器显示当前项目目录,也可在聊天输入 `/open-project` 走同一只读打开动作;该操作只打开本地目录,不初始化项目、不写项目文件、不切换工作区。主窗口头部显示最近 `.agent/run.latest.json` 的 run 状态摘要和当前项目预览状态,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。
|
||||
- 首页、项目组页和项目开发页共用单窗口壳的全局运行时配置弹窗,读写 Tauri 应用配置目录中的 `game-creator.config.json`;正式 Supervisor 项目页缺配置时只显示错误,不自动打开该弹窗。API Key 仍不进入本地项目、trace、manifest 或聊天记录。
|
||||
- 首页发送和项目组新建都通过 Tauri 原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。
|
||||
|
||||
-218
@@ -1,218 +0,0 @@
|
||||
/* @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> = {},
|
||||
): 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<void> | 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<ConfirmWechatProfileRechargeOrderResponse>((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();
|
||||
});
|
||||
});
|
||||
-307
@@ -1,307 +0,0 @@
|
||||
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<ConfirmWechatProfileRechargeOrderResponse>;
|
||||
watchOrder?: (
|
||||
orderId: string,
|
||||
options: { signal: AbortSignal },
|
||||
) => Promise<ConfirmWechatProfileRechargeOrderResponse>;
|
||||
onCenterReceived: (
|
||||
response: ConfirmWechatProfileRechargeOrderResponse['center'],
|
||||
) => void;
|
||||
onPaid?: () => void | Promise<void>;
|
||||
onTerminal?: (response: ConfirmWechatProfileRechargeOrderResponse) => void;
|
||||
};
|
||||
|
||||
export function isWechatRechargeOrderTerminal(
|
||||
order: Pick<ProfileRechargeOrder, 'status' | 'expirationCheckedAt'>,
|
||||
) {
|
||||
return !(
|
||||
order.status === 'pending' ||
|
||||
(order.status === 'expired' && !order.expirationCheckedAt)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildRechargePaymentResult(
|
||||
order: Pick<ProfileRechargeOrder, 'status' | 'expirationCheckedAt'>,
|
||||
): 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<void>((resolve) => {
|
||||
window.setTimeout(resolve, delayMs);
|
||||
});
|
||||
}
|
||||
|
||||
export function useWechatNativeRechargeController({
|
||||
confirmOrder,
|
||||
watchOrder,
|
||||
onCenterReceived,
|
||||
onPaid,
|
||||
onTerminal,
|
||||
}: UseWechatNativeRechargeControllerArgs) {
|
||||
const [nativePayment, setNativePayment] =
|
||||
useState<PlatformProfileRechargeNativePaymentState | null>(null);
|
||||
const [paymentResult, setPaymentResult] =
|
||||
useState<RechargePaymentResult | null>(null);
|
||||
const activeOrderIdRef = useRef<string | null>(null);
|
||||
const lifecycleRef = useRef(0);
|
||||
const settledOrderIdsRef = useRef(new Set<string>());
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildRechargePaymentResult,
|
||||
useWechatNativeRechargeController,
|
||||
} from '../../../packages/shared/src/components/PlatformProfileRechargeModal/useWechatNativeRechargeController';
|
||||
export type { RechargePaymentResult } from '../../../packages/shared/src/components/PlatformProfileRechargeModal/useWechatNativeRechargeController';
|
||||
import type { PlatformProfileRechargeNativePaymentState } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
|
||||
import {
|
||||
type ConfirmWechatProfileRechargeOrderResponse,
|
||||
type ProfileRechargeCenterResponse,
|
||||
@@ -55,6 +51,8 @@ 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;
|
||||
@@ -70,10 +68,26 @@ 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 &&
|
||||
@@ -225,6 +239,72 @@ async function confirmWechatRechargeOrderUntilSettled(
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmWechatRechargeOrderQuickly(
|
||||
orderId: string,
|
||||
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
|
||||
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<ProfileRechargeOrder, 'status' | 'expirationCheckedAt'>,
|
||||
): 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,
|
||||
@@ -248,10 +328,14 @@ export function usePlatformProfileCenterController({
|
||||
useState<ProfileRechargeCenterResponse | null>(null);
|
||||
const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false);
|
||||
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
||||
const [rechargePaymentResult, setRechargePaymentResult] =
|
||||
useState<RechargePaymentResult | null>(null);
|
||||
const [
|
||||
wechatRechargeOrderConfirmationState,
|
||||
setWechatRechargeOrderConfirmationState,
|
||||
] = useState<WechatRechargeOrderConfirmationState | null>(null);
|
||||
const [nativeWechatPayment, setNativeWechatPayment] =
|
||||
useState<NativeWechatPaymentState | null>(null);
|
||||
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
|
||||
useState<string | null>(null);
|
||||
const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false);
|
||||
@@ -296,27 +380,6 @@ export function usePlatformProfileCenterController({
|
||||
useCopyFeedback();
|
||||
const pendingWechatRechargeOrderIdRef = useRef<string | null>(null);
|
||||
const confirmingWechatRechargeOrderIdRef = useRef<string | null>(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(() => {
|
||||
@@ -378,8 +441,8 @@ export function usePlatformProfileCenterController({
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
confirmingWechatRechargeOrderIdRef.current = null;
|
||||
setWechatRechargeOrderConfirmationState(null);
|
||||
discardNativePayment();
|
||||
}, [discardNativePayment, loadRechargeCenter]);
|
||||
setNativeWechatPayment(null);
|
||||
}, [loadRechargeCenter]);
|
||||
|
||||
const handleWechatPayResult = useCallback(() => {
|
||||
const payResult = readWechatPayResultFromHash();
|
||||
@@ -412,7 +475,7 @@ export function usePlatformProfileCenterController({
|
||||
setRechargePaymentResult(null);
|
||||
void confirmWechatRechargeOrderUntilSettled(orderId)
|
||||
.then((response) => {
|
||||
const result = buildRechargePaymentResult(response.order);
|
||||
const result = buildRechargePaymentResultForOrder(response.order);
|
||||
const isPaid = result.kind === 'success';
|
||||
setRechargeCenter(response.center);
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
@@ -457,7 +520,7 @@ export function usePlatformProfileCenterController({
|
||||
|
||||
clearWechatPayResultHash();
|
||||
return true;
|
||||
}, [onRechargeSuccess, refreshRechargeState, setRechargePaymentResult]);
|
||||
}, [onRechargeSuccess, refreshRechargeState]);
|
||||
|
||||
const pollWechatPayResultFromHash = useCallback(
|
||||
() => handleWechatPayResult(),
|
||||
@@ -479,7 +542,7 @@ export function usePlatformProfileCenterController({
|
||||
setRechargePaymentResult(null);
|
||||
void confirmWechatRechargeOrderUntilSettled(orderId)
|
||||
.then((response) => {
|
||||
const result = buildRechargePaymentResult(response.order);
|
||||
const result = buildRechargePaymentResultForOrder(response.order);
|
||||
const isPaid = result.kind === 'success';
|
||||
setRechargeCenter(response.center);
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
@@ -501,7 +564,7 @@ export function usePlatformProfileCenterController({
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}, [nativeWechatPayment, onRechargeSuccess, setRechargePaymentResult]);
|
||||
}, [nativeWechatPayment, onRechargeSuccess]);
|
||||
|
||||
const openRechargeModal = useCallback(() => {
|
||||
if (!currentUser) {
|
||||
@@ -529,12 +592,14 @@ export function usePlatformProfileCenterController({
|
||||
}, [openRechargeModal, openRewardCodeModal, showRechargeEntry]);
|
||||
|
||||
const closeNativeWechatPayment = useCallback(() => {
|
||||
if (nativeWechatPayment?.isConfirming) {
|
||||
return;
|
||||
}
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
closeSharedNativePayment();
|
||||
}, [closeSharedNativePayment, nativeWechatPayment?.isConfirming]);
|
||||
setNativeWechatPayment((current) => {
|
||||
if (current?.isConfirming) {
|
||||
return current;
|
||||
}
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const buyRechargeProduct = useCallback(
|
||||
(product: ProfileRechargeProduct) => {
|
||||
@@ -550,7 +615,7 @@ export function usePlatformProfileCenterController({
|
||||
setRechargeError(null);
|
||||
setRechargePaymentResult(null);
|
||||
setWechatRechargeOrderConfirmationState(null);
|
||||
discardNativePayment();
|
||||
setNativeWechatPayment(null);
|
||||
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
|
||||
.then(async (response) => {
|
||||
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
|
||||
@@ -583,7 +648,7 @@ export function usePlatformProfileCenterController({
|
||||
});
|
||||
void confirmWechatRechargeOrderUntilSettled(response.order.orderId)
|
||||
.then((confirmResponse) => {
|
||||
const result = buildRechargePaymentResult(
|
||||
const result = buildRechargePaymentResultForOrder(
|
||||
confirmResponse.order,
|
||||
);
|
||||
const isPaid = result.kind === 'success';
|
||||
@@ -621,9 +686,23 @@ 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);
|
||||
beginNativePayment(response);
|
||||
setNativeWechatPayment({
|
||||
...wechatNativePayment,
|
||||
codeUrl,
|
||||
expiresAt,
|
||||
orderId: response.order.orderId,
|
||||
productTitle: response.order.productTitle,
|
||||
amountCents: response.order.amountCents,
|
||||
isConfirming: false,
|
||||
});
|
||||
setSubmittingRechargeProductId(null);
|
||||
return;
|
||||
}
|
||||
@@ -632,7 +711,7 @@ export function usePlatformProfileCenterController({
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
discardNativePayment();
|
||||
setNativeWechatPayment(null);
|
||||
if (
|
||||
paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL &&
|
||||
getHostRuntime().kind === 'wechat_mini_program' &&
|
||||
@@ -680,15 +759,127 @@ export function usePlatformProfileCenterController({
|
||||
setSubmittingRechargeProductId(null);
|
||||
});
|
||||
},
|
||||
[
|
||||
beginNativePayment,
|
||||
discardNativePayment,
|
||||
onRechargeSuccess,
|
||||
setRechargePaymentResult,
|
||||
submittingRechargeProductId,
|
||||
],
|
||||
[onRechargeSuccess, 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(() => {
|
||||
|
||||
Reference in New Issue
Block a user