diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 7b8413c61..c4319a94c 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -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) diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 8b5b25ca1..8cd51b434 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -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'); diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index d07505ace..79eecfac1 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -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 })); diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index bf8eacd47..1c19e8f16 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -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, name: string) { + const normalizedName = name.toLowerCase(); + return Object.keys(headers).some((key) => key.toLowerCase() === normalizedName); +} + +function setHeaderIfMissing( + headers: Record, + name: string, + value: string | null | undefined, +) { + const trimmedValue = value?.trim(); + if (!trimmedValue || hasHeader(headers, name)) { + return; + } + + headers[name] = trimmedValue; +} + +function attachHostRuntimeHeaders(headers: Record) { + let runtime: ReturnType; + 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 = {}, ) { - 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) {