fix: lock recharge flow until virtual payment settles
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
const { fetchWithApiAuthMock, requestJsonMock } = vi.hoisted(() => ({
|
||||
fetchWithApiAuthMock: vi.fn(),
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
submitRpgProfileFeedback,
|
||||
syncRpgProfileBrowseHistory,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
watchWechatRpgProfileRechargeOrder,
|
||||
} from './rpgProfileClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
@@ -21,9 +23,30 @@ vi.mock('../apiClient', () => ({
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
},
|
||||
fetchWithApiAuth: fetchWithApiAuthMock,
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
function createSseResponse(bodyText: string) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(bodyText));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithApiAuthMock.mockReset();
|
||||
});
|
||||
|
||||
describe('rpgProfileClient browse history routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
@@ -231,3 +254,86 @@ describe('rpgProfileClient feedback routes', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rpgProfileClient recharge order events', () => {
|
||||
beforeEach(() => {
|
||||
fetchWithApiAuthMock.mockReset();
|
||||
});
|
||||
|
||||
it('waits for a non-pending order event before completing the SSE watch', async () => {
|
||||
const pendingOrder = {
|
||||
orderId: 'order-wechat-sse-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending',
|
||||
paymentChannel: 'wechat_mp_virtual',
|
||||
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 paidOrder = {
|
||||
...pendingOrder,
|
||||
status: 'paid',
|
||||
paidAt: '2026-04-25T10:01:00Z',
|
||||
providerTransactionId: 'wx-sse-1',
|
||||
pointsDelta: 120,
|
||||
};
|
||||
fetchWithApiAuthMock.mockResolvedValueOnce(
|
||||
createSseResponse(
|
||||
[
|
||||
'event: order',
|
||||
`data: ${JSON.stringify({ order: pendingOrder, center })}`,
|
||||
'',
|
||||
'event: order',
|
||||
`data: ${JSON.stringify({
|
||||
order: paidOrder,
|
||||
center: {
|
||||
...center,
|
||||
walletBalance: 120,
|
||||
hasPointsRecharged: true,
|
||||
},
|
||||
})}`,
|
||||
'',
|
||||
'event: done',
|
||||
'data: {"orderId":"order-wechat-sse-1","status":"paid"}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await watchWechatRpgProfileRechargeOrder(
|
||||
'order-wechat-sse-1',
|
||||
);
|
||||
|
||||
expect(fetchWithApiAuthMock).toHaveBeenCalledWith(
|
||||
'/api/profile/recharge/orders/order-wechat-sse-1/wechat/events',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result.order.status).toBe('paid');
|
||||
expect(result.center.walletBalance).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,8 +19,10 @@ import type {
|
||||
SubmitProfileFeedbackRequest,
|
||||
SubmitProfileFeedbackResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { appendApiErrorRequestId, parseApiErrorMessage } from '../../../packages/shared/src/http';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { fetchWithApiAuth } from '../apiClient';
|
||||
import {
|
||||
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
requestRpgRuntimeJson,
|
||||
@@ -116,6 +118,235 @@ export function confirmWechatRpgProfileRechargeOrder(
|
||||
);
|
||||
}
|
||||
|
||||
type RechargeOrderSseEvent =
|
||||
| {
|
||||
type: 'order';
|
||||
payload: ConfirmWechatProfileRechargeOrderResponse;
|
||||
}
|
||||
| {
|
||||
type: 'done';
|
||||
payload: { orderId: string; status: string };
|
||||
}
|
||||
| {
|
||||
type: 'error';
|
||||
payload: { message: string };
|
||||
};
|
||||
|
||||
function findSseEventBoundary(buffer: string) {
|
||||
const lfBoundary = buffer.indexOf('\n\n');
|
||||
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (lfBoundary === -1 && crlfBoundary === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lfBoundary === -1) {
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
|
||||
return {
|
||||
index: lfBoundary,
|
||||
length: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string) {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(data: string) {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRechargeOrderSseEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
): RechargeOrderSseEvent | null {
|
||||
if (eventName === 'order' && parsed.order && parsed.center) {
|
||||
return {
|
||||
type: 'order',
|
||||
payload: parsed as ConfirmWechatProfileRechargeOrderResponse,
|
||||
};
|
||||
}
|
||||
|
||||
if (eventName === 'done') {
|
||||
const orderId =
|
||||
typeof parsed.orderId === 'string' ? parsed.orderId.trim() : '';
|
||||
const status = typeof parsed.status === 'string' ? parsed.status.trim() : '';
|
||||
if (orderId && status) {
|
||||
return {
|
||||
type: 'done',
|
||||
payload: { orderId, status },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (eventName === 'error') {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: '';
|
||||
return {
|
||||
type: 'error',
|
||||
payload: { message },
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function watchWechatRpgProfileRechargeOrder(
|
||||
orderId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
|
||||
const response = await fetchWithApiAuth(
|
||||
`/api/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/events`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
signal: options.signal,
|
||||
},
|
||||
{
|
||||
skipRefresh: options.skipRefresh,
|
||||
skipAuth: options.skipAuth,
|
||||
authImpact: options.authImpact,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(
|
||||
appendApiErrorRequestId(
|
||||
parseApiErrorMessage(responseText, '订阅充值订单状态失败'),
|
||||
response.headers.get('x-request-id'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let finalResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
||||
let lastResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
||||
let streamDone = false;
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeRechargeOrderSseEvent(eventName, parsed);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalized.type === 'order') {
|
||||
lastResponse = normalized.payload;
|
||||
if (normalized.payload.order.status !== 'pending') {
|
||||
finalResponse = normalized.payload;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalized.type === 'done') {
|
||||
streamDone = true;
|
||||
if (!finalResponse && lastResponse) {
|
||||
finalResponse = lastResponse;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(normalized.payload.message || '订阅充值订单状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
if (finalResponse) {
|
||||
break;
|
||||
}
|
||||
if (streamDone) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
consumeBuffer();
|
||||
|
||||
if (!finalResponse) {
|
||||
if (lastResponse) {
|
||||
finalResponse = lastResponse;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalResponse) {
|
||||
throw new Error('充值订单状态流返回不完整');
|
||||
}
|
||||
|
||||
return finalResponse;
|
||||
}
|
||||
|
||||
export function submitRpgProfileFeedback(
|
||||
payload: SubmitProfileFeedbackRequest,
|
||||
options: RuntimeRequestOptions = {},
|
||||
|
||||
Reference in New Issue
Block a user