Merge branch 'hermes/wechat'

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md
#	docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md
#	server-rs/crates/module-runtime/src/errors.rs
#	src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
#	src/components/rpg-entry/RpgEntryHomeView.tsx
This commit is contained in:
2026-05-15 11:32:51 +08:00
23 changed files with 2325 additions and 107 deletions

View File

@@ -0,0 +1,118 @@
import { describe, expect, test } from 'vitest';
import {
resolveProfileRechargePaymentChannel,
shouldShowRechargeEntry,
WECHAT_H5_PAYMENT_CHANNEL,
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
WECHAT_NATIVE_PAYMENT_CHANNEL,
} from './paymentPlatform';
describe('resolveProfileRechargePaymentChannel', () => {
test('小程序运行态选择 wechat_mp', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '?clientRuntime=wechat_mini_program' },
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
}),
).toBe(WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL);
});
test('移动网页选择 wechat_h5', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '' },
navigator: {
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile',
},
}),
).toBe(WECHAT_H5_PAYMENT_CHANNEL);
});
test('微信内 H5 首版仍选择 wechat_h5', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '' },
navigator: {
userAgent:
'Mozilla/5.0 (Linux; Android 14) AppleWebKit MicroMessenger/8.0 Mobile',
},
}),
).toBe(WECHAT_H5_PAYMENT_CHANNEL);
});
test('桌面网页选择 wechat_native', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '' },
navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
matchMedia: () => ({ matches: false }) as unknown as MediaQueryList,
}),
).toBe(WECHAT_NATIVE_PAYMENT_CHANNEL);
});
test('桌面微信内网页选择 wechat_native', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '' },
navigator: {
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit MicroMessenger/8.0',
},
matchMedia: () => ({ matches: false }) as unknown as MediaQueryList,
}),
).toBe(WECHAT_NATIVE_PAYMENT_CHANNEL);
});
test('默认路径永远不会解析成 mock', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '' },
navigator: { userAgent: '' },
matchMedia: () => ({ matches: false }) as unknown as MediaQueryList,
}),
).not.toBe('mock');
});
});
describe('shouldShowRechargeEntry', () => {
test('小程序运行态显示充值入口', () => {
expect(
shouldShowRechargeEntry({
location: { search: '?clientRuntime=wechat_mini_program' },
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
}),
).toBe(true);
});
test('微信内网页显示充值入口', () => {
expect(
shouldShowRechargeEntry({
location: { search: '' },
navigator: {
userAgent:
'Mozilla/5.0 (Linux; Android 14) AppleWebKit MicroMessenger/8.0 Mobile',
},
}),
).toBe(true);
});
test('普通浏览器不显示充值入口', () => {
expect(
shouldShowRechargeEntry({
location: { search: '' },
navigator: {
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile',
},
}),
).toBe(false);
expect(
shouldShowRechargeEntry({
location: { search: '' },
navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,95 @@
export const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
export const WECHAT_H5_PAYMENT_CHANNEL = 'wechat_h5';
export const WECHAT_NATIVE_PAYMENT_CHANNEL = 'wechat_native';
export const MOCK_PAYMENT_CHANNEL = 'mock';
export type ProfileRechargeWechatPaymentChannel =
| typeof WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
| typeof WECHAT_H5_PAYMENT_CHANNEL
| typeof WECHAT_NATIVE_PAYMENT_CHANNEL;
type PaymentPlatformNavigator = Pick<Navigator, 'userAgent' | 'maxTouchPoints'>;
export type PaymentPlatformContext = {
location?: Pick<Location, 'search'> | null;
navigator?: Partial<PaymentPlatformNavigator> | null;
matchMedia?: Window['matchMedia'] | null;
};
export function shouldShowRechargeEntry(
context: PaymentPlatformContext = {},
) {
const location =
context.location ?? (typeof window !== 'undefined' ? window.location : null);
const navigatorLike =
context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null);
return (
isWechatMiniProgramRuntime(location) ||
isWechatBrowserRuntime(navigatorLike)
);
}
export function resolveProfileRechargePaymentChannel(
context: PaymentPlatformContext = {},
): ProfileRechargeWechatPaymentChannel {
const location =
context.location ??
(typeof window !== 'undefined' ? window.location : null);
const navigatorLike =
context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null);
const matchMedia =
context.matchMedia ??
(typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia.bind(window)
: null);
if (isWechatMiniProgramRuntime(location)) {
return WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL;
}
if (isMobileWebRuntime(navigatorLike, matchMedia)) {
return WECHAT_H5_PAYMENT_CHANNEL;
}
return WECHAT_NATIVE_PAYMENT_CHANNEL;
}
export function isManualMockPaymentChannel(paymentChannel: string) {
return paymentChannel.trim() === MOCK_PAYMENT_CHANNEL;
}
function isWechatMiniProgramRuntime(
location: Pick<Location, 'search'> | null | undefined,
) {
const params = new URLSearchParams(location?.search ?? '');
return (
params.get('clientRuntime') === 'wechat_mini_program' ||
params.get('clientType') === 'mini_program'
);
}
function isWechatBrowserRuntime(
navigatorLike: Partial<PaymentPlatformNavigator> | null | undefined,
) {
return (
navigatorLike?.userAgent?.toLowerCase().includes('micromessenger') ??
false
);
}
function isMobileWebRuntime(
navigatorLike: Partial<PaymentPlatformNavigator> | null | undefined,
matchMedia: Window['matchMedia'] | null | undefined,
) {
const userAgent = navigatorLike?.userAgent?.toLowerCase() ?? '';
if (/android|iphone|ipad|ipod|mobile|windows phone/u.test(userAgent)) {
return true;
}
if ((navigatorLike?.maxTouchPoints ?? 0) > 1) {
return true;
}
return Boolean(matchMedia?.('(max-width: 767px)').matches);
}

View File

@@ -0,0 +1,3 @@
export function redirectToPaymentUrl(url: string) {
window.location.assign(url);
}

View File

@@ -67,9 +67,7 @@ export function getRpgProfileDashboard(options: RuntimeRequestOptions = {}) {
);
}
export function getRpgProfileWalletLedger(
options: RuntimeRequestOptions = {},
) {
export function getRpgProfileWalletLedger(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfileWalletLedgerResponse>(
'/profile/wallet-ledger',
{ method: 'GET' },
@@ -91,7 +89,7 @@ export function getRpgProfileRechargeCenter(
export function createRpgProfileRechargeOrder(
productId: string,
paymentChannel = 'mock',
paymentChannel: string,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<CreateProfileRechargeOrderResponse>(
@@ -227,12 +225,13 @@ export async function resumeRpgProfileSaveArchive(
worldKey: string,
options: RuntimeRequestOptions = {},
) {
const response = await requestRpgRuntimeJson<ProfileSaveArchiveResumeResponse>(
`/profile/save-archives/${encodeURIComponent(worldKey)}`,
{ method: 'POST' },
'恢复存档失败',
options,
);
const response =
await requestRpgRuntimeJson<ProfileSaveArchiveResumeResponse>(
`/profile/save-archives/${encodeURIComponent(worldKey)}`,
{ method: 'POST' },
'恢复存档失败',
options,
);
return {
entry: response.entry,