修复充值过期补偿确认竞态
调整微信充值确认终态判断,未完成补偿查单的 expired 不再提前结束。 让 SSE 客户端和前端确认流程等待补偿后的 paid 或已检查 expired。 补充 unchecked expired 到 paid 的回归测试。
This commit is contained in:
@@ -464,7 +464,7 @@ pub async fn stream_wechat_profile_recharge_order_events(
|
||||
"order",
|
||||
&initial_response,
|
||||
));
|
||||
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
|
||||
if is_wechat_profile_recharge_order_terminal_for_confirmation(&order) {
|
||||
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
|
||||
"done",
|
||||
&WechatProfileRechargeOrderDoneEvent {
|
||||
@@ -499,7 +499,7 @@ pub async fn stream_wechat_profile_recharge_order_events(
|
||||
"order",
|
||||
&response,
|
||||
));
|
||||
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
|
||||
if is_wechat_profile_recharge_order_terminal_for_confirmation(&order) {
|
||||
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
|
||||
"done",
|
||||
&WechatProfileRechargeOrderDoneEvent {
|
||||
@@ -1273,6 +1273,22 @@ fn build_profile_recharge_order_status(status: RuntimeProfileRechargeOrderStatus
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn is_wechat_profile_recharge_order_terminal_for_confirmation(
|
||||
order: &RuntimeProfileRechargeOrderRecord,
|
||||
) -> bool {
|
||||
if order.status == RuntimeProfileRechargeOrderStatus::Pending {
|
||||
return false;
|
||||
}
|
||||
|
||||
if order.status == RuntimeProfileRechargeOrderStatus::Expired
|
||||
&& order.expiration_checked_at_micros.is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn wechat_profile_recharge_sse_json_event<T>(event_name: &str, payload: &T) -> Event
|
||||
where
|
||||
T: Serialize,
|
||||
@@ -2146,7 +2162,9 @@ mod tests {
|
||||
use super::{
|
||||
build_wechat_virtual_pay_params, calc_wechat_virtual_payment_pay_signature_with_key,
|
||||
calc_wechat_virtual_payment_user_signature_with_key,
|
||||
format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata,
|
||||
format_profile_wallet_ledger_source_type,
|
||||
is_wechat_profile_recharge_order_terminal_for_confirmation,
|
||||
normalize_admin_invite_code_metadata,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
@@ -3228,6 +3246,58 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn build_test_recharge_order(
|
||||
status: RuntimeProfileRechargeOrderStatus,
|
||||
expiration_checked_at_micros: Option<i64>,
|
||||
) -> RuntimeProfileRechargeOrderRecord {
|
||||
RuntimeProfileRechargeOrderRecord {
|
||||
order_id: "confirm-terminal-order".to_string(),
|
||||
user_id: "user-terminal".to_string(),
|
||||
product_id: "points_60".to_string(),
|
||||
product_title: "60娉ョ偣".to_string(),
|
||||
kind: RuntimeProfileRechargeProductKind::Points,
|
||||
amount_cents: 600,
|
||||
status,
|
||||
payment_channel: "wechat_native".to_string(),
|
||||
paid_at: None,
|
||||
paid_at_micros: None,
|
||||
provider_transaction_id: None,
|
||||
created_at: "2026-04-25T10:00:00Z".to_string(),
|
||||
created_at_micros: 1_777_111_200_000_000,
|
||||
points_delta: 0,
|
||||
membership_expires_at: None,
|
||||
membership_expires_at_micros: None,
|
||||
expired_at: None,
|
||||
expired_at_micros: None,
|
||||
expiration_checked_at: expiration_checked_at_micros
|
||||
.map(|_| "2026-04-25T10:05:02Z".to_string()),
|
||||
expiration_checked_at_micros,
|
||||
expiration_provider_state: None,
|
||||
expiration_last_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_recharge_order_waits_for_expiration_compensation_check() {
|
||||
let order = build_test_recharge_order(RuntimeProfileRechargeOrderStatus::Expired, None);
|
||||
|
||||
assert!(!is_wechat_profile_recharge_order_terminal_for_confirmation(
|
||||
&order
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_expired_recharge_order_is_terminal_for_confirmation() {
|
||||
let order = build_test_recharge_order(
|
||||
RuntimeProfileRechargeOrderStatus::Expired,
|
||||
Some(1_777_111_502_000_000),
|
||||
);
|
||||
|
||||
assert!(is_wechat_profile_recharge_order_terminal_for_confirmation(
|
||||
&order
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wechat_virtual_payment_sandbox_requires_sandbox_app_key() {
|
||||
let state = seed_authenticated_state_with_config(AppConfig {
|
||||
|
||||
@@ -206,11 +206,25 @@ function waitWechatPayConfirmDelay(delayMs: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function isWechatRechargeOrderTerminalForConfirmation(
|
||||
order: Pick<ProfileRechargeOrder, 'status' | 'expirationCheckedAt'>,
|
||||
) {
|
||||
if (order.status === 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (order.status === 'expired' && !order.expirationCheckedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function confirmWechatRechargeOrderUntilSettled(
|
||||
orderId: string,
|
||||
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
|
||||
let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
|
||||
if (latestResponse.order.status !== 'pending') {
|
||||
if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) {
|
||||
return latestResponse;
|
||||
}
|
||||
|
||||
@@ -218,7 +232,7 @@ async function confirmWechatRechargeOrderUntilSettled(
|
||||
await waitWechatPayConfirmDelay(delayMs);
|
||||
|
||||
latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
|
||||
if (latestResponse.order.status !== 'pending') {
|
||||
if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) {
|
||||
return latestResponse;
|
||||
}
|
||||
}
|
||||
@@ -235,7 +249,7 @@ async function confirmWechatRechargeOrderQuickly(
|
||||
orderId: string,
|
||||
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
|
||||
let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
|
||||
if (latestResponse.order.status !== 'pending') {
|
||||
if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) {
|
||||
return latestResponse;
|
||||
}
|
||||
|
||||
@@ -243,7 +257,7 @@ async function confirmWechatRechargeOrderQuickly(
|
||||
await waitWechatPayConfirmDelay(delayMs);
|
||||
|
||||
latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
|
||||
if (latestResponse.order.status !== 'pending') {
|
||||
if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) {
|
||||
return latestResponse;
|
||||
}
|
||||
}
|
||||
@@ -252,7 +266,7 @@ async function confirmWechatRechargeOrderQuickly(
|
||||
}
|
||||
|
||||
function buildRechargePaymentResultForOrder(
|
||||
order: Pick<ProfileRechargeOrder, 'status'>,
|
||||
order: Pick<ProfileRechargeOrder, 'status' | 'expirationCheckedAt'>,
|
||||
): RechargePaymentResult {
|
||||
switch (order.status) {
|
||||
case 'paid':
|
||||
@@ -262,6 +276,13 @@ function buildRechargePaymentResultForOrder(
|
||||
message: '已到账,账户状态已刷新。',
|
||||
};
|
||||
case 'expired':
|
||||
if (!order.expirationCheckedAt) {
|
||||
return {
|
||||
kind: 'pending',
|
||||
title: '支付处理中',
|
||||
message: '正在等待到账状态确认,请稍后查看余额或会员状态。',
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'expired',
|
||||
title: '支付已过期',
|
||||
|
||||
@@ -2732,6 +2732,8 @@ test('profile native qr confirmation closes qr dialog when order is expired', as
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
expiredAt: '2026-04-25T10:05:00Z',
|
||||
expirationCheckedAt: '2026-04-25T10:05:02Z',
|
||||
expirationProviderState: 'NOTPAY',
|
||||
},
|
||||
center: {
|
||||
walletBalance: 0,
|
||||
@@ -2764,6 +2766,99 @@ test('profile native qr confirmation closes qr dialog when order is expired', as
|
||||
expect(onRechargeSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('profile native qr confirmation keeps qr dialog while expired order is awaiting compensation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
mockDesktopLayout();
|
||||
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||
order: {
|
||||
orderId: 'order-native-expired-unchecked',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending' as const,
|
||||
paymentChannel: 'wechat_native',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 0,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
},
|
||||
wechatNativePayment: {
|
||||
codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-expired-unchecked',
|
||||
expiresAt: '2099-01-01T00:05:00Z',
|
||||
},
|
||||
});
|
||||
mockConfirmWechatRpgProfileRechargeOrder.mockResolvedValue({
|
||||
order: {
|
||||
orderId: 'order-native-expired-unchecked',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'expired' as const,
|
||||
paymentChannel: 'wechat_native',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
expiredAt: '2026-04-25T10:05:00Z',
|
||||
expirationCheckedAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 0,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
await user.click(
|
||||
within(shortcutRegion).getByRole('button', { name: /充值/u }),
|
||||
);
|
||||
await user.click(await screen.findByRole('button', { name: /60泥点/u }));
|
||||
await user.click(await screen.findByRole('button', { name: '我已支付' }));
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(3);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'暂未确认到账,请确认付款完成后再点一次。',
|
||||
undefined,
|
||||
{
|
||||
timeout: 5000,
|
||||
},
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('dialog', {
|
||||
name: '微信扫码支付',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('dialog', { name: '支付已过期' })).toBeNull();
|
||||
expect(onRechargeSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('profile native qr confirmation keeps qr dialog when payment is still pending', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
|
||||
@@ -398,7 +398,85 @@ describe('rpgProfileClient recharge order events', () => {
|
||||
expect(result.center.walletBalance).toBe(120);
|
||||
});
|
||||
|
||||
it('treats an expired order event as a settled SSE result', async () => {
|
||||
it('waits past unchecked expired order events until compensation publishes paid', async () => {
|
||||
const pendingOrder = {
|
||||
orderId: 'order-wechat-sse-expired-then-paid',
|
||||
productId: 'points_60',
|
||||
productTitle: '60娉ョ偣',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending',
|
||||
paymentChannel: 'wechat_native',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
};
|
||||
const center = {
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
};
|
||||
const uncheckedExpiredOrder = {
|
||||
...pendingOrder,
|
||||
status: 'expired',
|
||||
expiredAt: '2026-04-25T10:05:00Z',
|
||||
expirationCheckedAt: null,
|
||||
};
|
||||
const paidOrder = {
|
||||
...pendingOrder,
|
||||
status: 'paid',
|
||||
paidAt: '2026-04-25T10:05:03Z',
|
||||
providerTransactionId: 'wx-expired-compensated-1',
|
||||
pointsDelta: 120,
|
||||
};
|
||||
fetchWithApiAuthMock.mockResolvedValueOnce(
|
||||
createSseResponse(
|
||||
[
|
||||
'event: order',
|
||||
`data: ${JSON.stringify({ order: pendingOrder, center })}`,
|
||||
'',
|
||||
'event: order',
|
||||
`data: ${JSON.stringify({ order: uncheckedExpiredOrder, center })}`,
|
||||
'',
|
||||
'event: order',
|
||||
`data: ${JSON.stringify({
|
||||
order: paidOrder,
|
||||
center: {
|
||||
...center,
|
||||
walletBalance: 120,
|
||||
hasPointsRecharged: true,
|
||||
},
|
||||
})}`,
|
||||
'',
|
||||
'event: done',
|
||||
'data: {"orderId":"order-wechat-sse-expired-then-paid","status":"paid"}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await watchWechatRpgProfileRechargeOrder(
|
||||
'order-wechat-sse-expired-then-paid',
|
||||
);
|
||||
|
||||
expect(result.order.status).toBe('paid');
|
||||
expect(result.center.walletBalance).toBe(120);
|
||||
});
|
||||
|
||||
it('treats a checked expired order event as a settled SSE result', async () => {
|
||||
const pendingOrder = {
|
||||
orderId: 'order-wechat-sse-expired',
|
||||
productId: 'points_60',
|
||||
@@ -432,6 +510,8 @@ describe('rpgProfileClient recharge order events', () => {
|
||||
...pendingOrder,
|
||||
status: 'expired',
|
||||
expiredAt: '2026-04-25T10:05:00Z',
|
||||
expirationCheckedAt: '2026-04-25T10:05:02Z',
|
||||
expirationProviderState: 'NOTPAY',
|
||||
};
|
||||
fetchWithApiAuthMock.mockResolvedValueOnce(
|
||||
createSseResponse(
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileRechargeOrder,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileSaveArchiveListResponse,
|
||||
@@ -212,6 +213,20 @@ function normalizeRechargeOrderSseEvent(
|
||||
return null;
|
||||
}
|
||||
|
||||
function isWechatRechargeOrderTerminalForConfirmation(
|
||||
order: Pick<ProfileRechargeOrder, 'status' | 'expirationCheckedAt'>,
|
||||
) {
|
||||
if (order.status === 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (order.status === 'expired' && !order.expirationCheckedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function watchWechatRpgProfileRechargeOrder(
|
||||
orderId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
@@ -259,7 +274,9 @@ export async function watchWechatRpgProfileRechargeOrder(
|
||||
|
||||
if (normalized.type === 'order') {
|
||||
lastResponse = normalized.payload;
|
||||
if (normalized.payload.order.status !== 'pending') {
|
||||
if (
|
||||
isWechatRechargeOrderTerminalForConfirmation(normalized.payload.order)
|
||||
) {
|
||||
finalResponse = normalized.payload;
|
||||
return false;
|
||||
}
|
||||
@@ -267,7 +284,11 @@ export async function watchWechatRpgProfileRechargeOrder(
|
||||
}
|
||||
|
||||
if (normalized.type === 'done') {
|
||||
if (!finalResponse && lastResponse) {
|
||||
if (
|
||||
!finalResponse &&
|
||||
lastResponse &&
|
||||
isWechatRechargeOrderTerminalForConfirmation(lastResponse.order)
|
||||
) {
|
||||
finalResponse = lastResponse;
|
||||
}
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user