修复充值过期补偿确认竞态

调整微信充值确认终态判断,未完成补偿查单的 expired 不再提前结束。

让 SSE 客户端和前端确认流程等待补偿后的 paid 或已检查 expired。

补充 unchecked expired 到 paid 的回归测试。
This commit is contained in:
kdletters
2026-07-10 02:09:59 +08:00
parent 00ff425e03
commit 2de9b79e69
5 changed files with 298 additions and 11 deletions
@@ -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(
+23 -2
View File
@@ -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;