修复小程序内充值设备识别
为 API 请求和刷新请求补充宿主运行时请求头。 在小程序充值设备身份异常时清理旧 token 并重新拉起小程序登录。 补充小程序充值回归测试并隔离测试 spy。
This commit is contained in:
@@ -10,9 +10,16 @@ import {
|
||||
type RedeemProfileRewardCodeResponse,
|
||||
type WechatNativePayment,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { refreshStoredAccessToken } from '../../services/apiClient';
|
||||
import {
|
||||
clearStoredAccessToken,
|
||||
refreshStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
import { startWechatBind, type AuthUser } from '../../services/authService';
|
||||
import { requestHostPayment } from '../../services/host-bridge/hostBridge';
|
||||
import {
|
||||
getHostRuntime,
|
||||
requestHostLogin,
|
||||
requestHostPayment,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import {
|
||||
resolveProfileRechargeProductPaymentChannel,
|
||||
WECHAT_H5_PAYMENT_CHANNEL,
|
||||
@@ -47,6 +54,7 @@ const WECHAT_NATIVE_CONFIRM_RETRY_DELAYS_MS = [800, 1600] as const;
|
||||
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
|
||||
const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250;
|
||||
const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000;
|
||||
const WECHAT_RECHARGE_UNSUPPORTED_DEVICE_TEXT = '当前登录设备不支持充值';
|
||||
const WECHAT_JSAPI_MISSING_IDENTITY_TEXT = '缺少微信';
|
||||
|
||||
export type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
|
||||
@@ -87,6 +95,13 @@ function isWechatJsapiMissingIdentityError(error: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function isWechatRechargeUnsupportedDeviceError(error: unknown) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.message.includes(WECHAT_RECHARGE_UNSUPPORTED_DEVICE_TEXT)
|
||||
);
|
||||
}
|
||||
|
||||
type UsePlatformProfileCenterControllerArgs = {
|
||||
activeTab: string;
|
||||
isAuthenticated: boolean;
|
||||
@@ -642,15 +657,16 @@ export function usePlatformProfileCenterController({
|
||||
return;
|
||||
}
|
||||
if (paymentChannel === WECHAT_NATIVE_PAYMENT_CHANNEL) {
|
||||
const codeUrl = response.wechatNativePayment?.codeUrl?.trim();
|
||||
const expiresAt = response.wechatNativePayment?.expiresAt?.trim();
|
||||
if (!codeUrl || !expiresAt) {
|
||||
const wechatNativePayment = response.wechatNativePayment;
|
||||
const codeUrl = wechatNativePayment?.codeUrl?.trim();
|
||||
const expiresAt = wechatNativePayment?.expiresAt?.trim();
|
||||
if (!wechatNativePayment || !codeUrl || !expiresAt) {
|
||||
throw new Error('微信 Native 支付链接生成失败');
|
||||
}
|
||||
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
|
||||
setRechargeCenter(response.center);
|
||||
setNativeWechatPayment({
|
||||
...response.wechatNativePayment,
|
||||
...wechatNativePayment,
|
||||
codeUrl,
|
||||
expiresAt,
|
||||
orderId: response.order.orderId,
|
||||
@@ -667,6 +683,29 @@ export function usePlatformProfileCenterController({
|
||||
.catch((error: unknown) => {
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
setNativeWechatPayment(null);
|
||||
if (
|
||||
paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL &&
|
||||
getHostRuntime().kind === 'wechat_mini_program' &&
|
||||
isWechatRechargeUnsupportedDeviceError(error)
|
||||
) {
|
||||
clearStoredAccessToken();
|
||||
setSubmittingRechargeProductId(null);
|
||||
setRechargeError(null);
|
||||
setRechargePaymentResult({
|
||||
kind: 'pending',
|
||||
title: '需要重新登录',
|
||||
message: '正在打开微信小程序登录,请重新登录后再支付。',
|
||||
});
|
||||
void requestHostLogin().catch((miniProgramLoginError: unknown) => {
|
||||
setRechargePaymentResult(null);
|
||||
setRechargeError(
|
||||
miniProgramLoginError instanceof Error
|
||||
? miniProgramLoginError.message
|
||||
: '请在微信小程序内重新登录后再支付',
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
paymentChannel === WECHAT_JSAPI_PAYMENT_CHANNEL &&
|
||||
isWechatJsapiMissingIdentityError(error)
|
||||
|
||||
@@ -381,6 +381,7 @@ const {
|
||||
const {
|
||||
mockGetPublicAuthUserByCode,
|
||||
mockGetPublicAuthUserById,
|
||||
mockClearStoredAccessToken,
|
||||
mockRefreshStoredAccessToken,
|
||||
mockRequestJson,
|
||||
mockUpdateAuthProfile,
|
||||
@@ -403,6 +404,7 @@ const {
|
||||
avatarUrl: null,
|
||||
}),
|
||||
),
|
||||
mockClearStoredAccessToken: vi.fn(),
|
||||
mockRefreshStoredAccessToken: vi.fn(async () => 'jwt-refreshed-token'),
|
||||
mockRequestJson: vi.fn(async () => ({
|
||||
read: {
|
||||
@@ -418,6 +420,7 @@ const mockStartWechatBind = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS: {},
|
||||
clearStoredAccessToken: mockClearStoredAccessToken,
|
||||
refreshStoredAccessToken: mockRefreshStoredAccessToken,
|
||||
requestJson: mockRequestJson,
|
||||
}));
|
||||
@@ -1272,6 +1275,7 @@ function renderStatefulLoggedOutHomeView(
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
@@ -1752,6 +1756,41 @@ test('profile recharge modal posts virtual payment params in mini program web-vi
|
||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('profile recharge modal requests mini program login when stored token is not a mini program device', async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo: vi.fn((options: { success?: () => void }) => {
|
||||
options.success?.();
|
||||
}),
|
||||
},
|
||||
};
|
||||
const requestHostLogin = vi
|
||||
.spyOn(hostBridgeServices, 'requestHostLogin')
|
||||
.mockResolvedValueOnce(true);
|
||||
mockCreateRpgProfileRechargeOrder.mockRejectedValueOnce(
|
||||
new Error('当前登录设备不支持充值,请在微信环境内登录后重试'),
|
||||
);
|
||||
|
||||
renderProfileView();
|
||||
await openRechargeModal(user);
|
||||
await user.click(await screen.findByRole('button', { name: /60泥点/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||
'points_60',
|
||||
'wechat_mp_virtual',
|
||||
);
|
||||
});
|
||||
expect(mockClearStoredAccessToken).toHaveBeenCalledTimes(1);
|
||||
expect(requestHostLogin).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByRole('dialog', { name: '需要重新登录' })).toBeTruthy();
|
||||
expect(screen.getByText('正在打开微信小程序登录,请重新登录后再支付。')).toBeTruthy();
|
||||
expect(mockRedirectToPaymentUrl).not.toHaveBeenCalled();
|
||||
expect(mockRequestWechatJsapiPayment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('profile recharge modal posts membership goods virtual payment params in mini program web-view', async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
||||
|
||||
@@ -213,6 +213,84 @@ describe('apiClient', () => {
|
||||
expect(getStoredAccessToken()).toBe('fresh-token');
|
||||
});
|
||||
|
||||
it('attaches mini program client headers to refresh and protected requests', async () => {
|
||||
Object.assign(window, {
|
||||
location: {
|
||||
search:
|
||||
'?clientRuntime=wechat_mini_program&hostPlatform=ios&miniProgramEnv=release',
|
||||
},
|
||||
wx: {
|
||||
miniProgram: {
|
||||
postMessage: vi.fn(),
|
||||
},
|
||||
},
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
token: 'mini-token',
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
value: 11,
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestJson<{ value: number }>(
|
||||
'/api/runtime/protected',
|
||||
{ method: 'GET' },
|
||||
'读取受保护数据失败',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 11 });
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'x-client-type': 'mini_program',
|
||||
'x-client-runtime': 'wechat_mini_program',
|
||||
'x-client-platform': 'ios',
|
||||
'x-mini-program-env': 'release',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/runtime/protected',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer mini-token',
|
||||
'x-client-type': 'mini_program',
|
||||
'x-client-runtime': 'wechat_mini_program',
|
||||
'x-client-platform': 'ios',
|
||||
'x-mini-program-env': 'release',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not emit auth change events when 401 probe requests opt into silent mode', async () => {
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
|
||||
@@ -8,12 +8,17 @@ import {
|
||||
parseApiErrorMessage,
|
||||
unwrapApiResponse,
|
||||
} from '../../packages/shared/src/http';
|
||||
import { getHostRuntime } from './host-bridge/hostBridge';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
||||
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
|
||||
const REQUEST_ID_HEADER = 'x-request-id';
|
||||
const API_VERSION_HEADER = 'x-api-version';
|
||||
const ROUTE_VERSION_HEADER = 'x-route-version';
|
||||
const CLIENT_TYPE_HEADER = 'x-client-type';
|
||||
const CLIENT_RUNTIME_HEADER = 'x-client-runtime';
|
||||
const CLIENT_PLATFORM_HEADER = 'x-client-platform';
|
||||
const MINI_PROGRAM_ENV_HEADER = 'x-mini-program-env';
|
||||
const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 502, 503, 504];
|
||||
const DEFAULT_SAFE_RETRY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
@@ -101,6 +106,57 @@ function normalizeHeaders(headers?: HeadersInit) {
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
function hasHeader(headers: Record<string, string>, name: string) {
|
||||
const normalizedName = name.toLowerCase();
|
||||
return Object.keys(headers).some((key) => key.toLowerCase() === normalizedName);
|
||||
}
|
||||
|
||||
function setHeaderIfMissing(
|
||||
headers: Record<string, string>,
|
||||
name: string,
|
||||
value: string | null | undefined,
|
||||
) {
|
||||
const trimmedValue = value?.trim();
|
||||
if (!trimmedValue || hasHeader(headers, name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
headers[name] = trimmedValue;
|
||||
}
|
||||
|
||||
function attachHostRuntimeHeaders(headers: Record<string, string>) {
|
||||
let runtime: ReturnType<typeof getHostRuntime>;
|
||||
try {
|
||||
runtime = getHostRuntime();
|
||||
} catch {
|
||||
return headers;
|
||||
}
|
||||
|
||||
if (runtime.kind === 'wechat_mini_program') {
|
||||
setHeaderIfMissing(headers, CLIENT_TYPE_HEADER, 'mini_program');
|
||||
setHeaderIfMissing(
|
||||
headers,
|
||||
CLIENT_RUNTIME_HEADER,
|
||||
runtime.clientRuntime || 'wechat_mini_program',
|
||||
);
|
||||
setHeaderIfMissing(headers, CLIENT_PLATFORM_HEADER, runtime.hostPlatform);
|
||||
setHeaderIfMissing(headers, MINI_PROGRAM_ENV_HEADER, runtime.miniProgramEnv);
|
||||
return headers;
|
||||
}
|
||||
|
||||
if (runtime.kind === 'native_app') {
|
||||
setHeaderIfMissing(headers, CLIENT_TYPE_HEADER, 'native_app');
|
||||
setHeaderIfMissing(
|
||||
headers,
|
||||
CLIENT_RUNTIME_HEADER,
|
||||
runtime.clientRuntime || 'native_app',
|
||||
);
|
||||
setHeaderIfMissing(headers, CLIENT_PLATFORM_HEADER, runtime.hostPlatform);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildClientRequestId() {
|
||||
const randomId =
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
@@ -502,7 +558,7 @@ function withAuthorizationHeaders(
|
||||
headers?: HeadersInit,
|
||||
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
|
||||
) {
|
||||
const nextHeaders = normalizeHeaders(headers);
|
||||
const nextHeaders = attachHostRuntimeHeaders(normalizeHeaders(headers));
|
||||
const token = getStoredAccessToken();
|
||||
if (token && !options.skipAuth) {
|
||||
nextHeaders.Authorization = `Bearer ${token}`;
|
||||
@@ -531,9 +587,9 @@ async function refreshAccessToken() {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
headers: attachHostRuntimeHeaders({
|
||||
[API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
Reference in New Issue
Block a user