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