修复小程序内充值设备识别

为 API 请求和刷新请求补充宿主运行时请求头。

在小程序充值设备身份异常时清理旧 token 并重新拉起小程序登录。

补充小程序充值回归测试并隔离测试 spy。
This commit is contained in:
kdletters
2026-07-09 16:27:43 +08:00
parent 67fee3718d
commit c022bbb8a0
4 changed files with 221 additions and 9 deletions
@@ -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');
+78
View File
@@ -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 }));
+59 -3
View File
@@ -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) {