@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user