fix wechat mini program virtual payment flow

This commit is contained in:
kdletters
2026-05-28 00:41:06 +08:00
parent b43c3cd823
commit 9c6fa10301
10 changed files with 335 additions and 57 deletions

View File

@@ -9,6 +9,7 @@
- H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。
- `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。
- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端微信通知或查询确认后写入订单为准。
- 小程序 WebView 默认进入时会静默调用 `wx.login` 刷新后端微信登录态,避免历史登录用户只有前端 JWT、后端缺少 `session_key` 时无法生成虚拟支付签名。
## 关键文件
@@ -51,6 +52,9 @@ npm run check:encoding
## 注意事项
- 旧微信登录快照可能没有 `session_key`,用户需要在小程序内重新登录后再发起虚拟支付
- 旧微信登录快照可能没有 `session_key`;小程序 WebView 会在普通进入时静默刷新一次微信登录态,刷新失败时仍允许匿名打开 WebView但虚拟支付会继续由后端拦截并提示重新登录
- 小程序充值商品全部映射到虚拟支付;泥点使用 `short_series_coin`,会员使用 `short_series_goods`
- 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。
- 小程序页必须保留普通支付与虚拟支付双分支,按 pay params 字段判断调用 `wx.requestPayment``wx.requestVirtualPayment`
- 小程序支付承接页回传 `wx_pay_result` 时必须携带 `requestId:status:orderId[:error]`,并同时写入上一页 hash 与本地 storageWebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。
- 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。

View File

@@ -15,6 +15,7 @@ const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id
const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result';
const AUTH_ACTION_LOGIN = 'login';
const PAY_RESULT_RECHECK_DELAY_MS = 120;
function isConfiguredEntryUrl(value) {
const trimmed = String(value || '').trim();
@@ -45,6 +46,7 @@ function appendQuery(url, query) {
}
function appendHashParams(url, params) {
const nextKeys = new Set(Object.keys(params).filter((key) => params[key]));
const pairs = Object.keys(params)
.filter((key) => params[key])
.map(
@@ -58,8 +60,18 @@ function appendHashParams(url, params) {
const hashIndex = url.indexOf('#');
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
const separator = rawHash ? '&' : '';
return `${baseUrl}#${rawHash}${separator}${pairs.join('&')}`;
const keptHashParts = rawHash.split('&').filter((part) => {
if (!part) {
return false;
}
const [rawKey = ''] = part.split('=');
try {
return !nextKeys.has(decodeURIComponent(rawKey));
} catch (_error) {
return !nextKeys.has(rawKey);
}
});
return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`;
}
function parseBooleanQueryFlag(value) {
@@ -259,6 +271,18 @@ async function resolveAuthResult() {
};
}
async function refreshMiniProgramSessionSilently() {
if (!isConfiguredApiBaseUrl(API_BASE_URL)) {
return null;
}
try {
return await resolveAuthResult();
} catch (error) {
console.warn('[web-view] silent mini program login refresh failed', error);
return null;
}
}
Page({
data: {
authResult: null,
@@ -285,13 +309,14 @@ Page({
const forcedPhoneBinding = parseBooleanQueryFlag(query.phoneBindingRequired);
const returnToPreviousPage = shouldReturnToPreviousPage(query);
if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) {
const authResult = await refreshMiniProgramSessionSilently();
this.setData({
authResult: null,
authResult,
errorMessage: '',
loading: false,
phoneBindingRequired: false,
returnToPreviousPage: false,
webViewUrl: resolveWebViewUrl(null),
webViewUrl: resolveWebViewUrl(authResult),
});
return;
}
@@ -362,6 +387,13 @@ Page({
});
}
this.consumePayResult();
setTimeout(() => {
this.consumePayResult();
}, PAY_RESULT_RECHECK_DELAY_MS);
},
consumePayResult() {
const result = wx.getStorageSync(PAY_RESULT_STORAGE_KEY);
if (result && this.data.webViewUrl) {
wx.removeStorageSync(PAY_RESULT_STORAGE_KEY);

View File

@@ -64,6 +64,23 @@ function resolvePayStatus(error) {
return errCode === -2 || /cancel/i.test(errMsg) ? 'cancel' : 'fail';
}
function normalizePayError(error) {
if (!error) {
return '';
}
if (typeof error === 'string') {
return error;
}
try {
return JSON.stringify({
errCode: error.errCode,
errMsg: error.errMsg,
});
} catch (_error) {
return String(error.errMsg || error);
}
}
function requestOrdinaryPayment(payParams) {
return new Promise((resolve) => {
wx.requestPayment({
@@ -73,10 +90,13 @@ function requestOrdinaryPayment(payParams) {
signType: payParams.signType || 'RSA',
paySign: String(payParams.paySign || ''),
success() {
resolve('success');
resolve({ status: 'success', errorMessage: '' });
},
fail(error) {
resolve(resolvePayStatus(error));
resolve({
status: resolvePayStatus(error),
errorMessage: normalizePayError(error),
});
},
});
});
@@ -85,7 +105,10 @@ function requestOrdinaryPayment(payParams) {
function requestVirtualPayment(payParams) {
return new Promise((resolve) => {
if (!canUseVirtualPayment() || typeof wx.requestVirtualPayment !== 'function') {
resolve('fail');
resolve({
status: 'fail',
errorMessage: '当前微信基础库不支持 requestVirtualPayment',
});
return;
}
wx.requestVirtualPayment({
@@ -94,10 +117,13 @@ function requestVirtualPayment(payParams) {
paySig: String(payParams.paySig || ''),
signature: String(payParams.signature || ''),
success() {
resolve('success');
resolve({ status: 'success', errorMessage: '' });
},
fail(error) {
resolve(resolvePayStatus(error));
resolve({
status: resolvePayStatus(error),
errorMessage: normalizePayError(error),
});
},
});
});
@@ -112,8 +138,7 @@ function requestWechatPayment(payParams) {
const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
function appendPayResult(url, requestId, status) {
const value = `${requestId}:${status}`;
function appendPayResult(url, result) {
const hashIndex = String(url || '').indexOf('#');
const baseUrl =
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
@@ -121,23 +146,27 @@ function appendPayResult(url, requestId, status) {
const nextHash = rawHash
.split('&')
.filter((part) => part && !part.startsWith('wx_pay_result='))
.concat(`wx_pay_result=${encodeURIComponent(value)}`)
.concat(`wx_pay_result=${encodeURIComponent(result)}`)
.join('&');
return `${baseUrl}#${nextHash}`;
}
function notifyPreviousWebView(requestId, status) {
const result = `${requestId}:${status}`;
function buildPayResultValue(requestId, orderId, payResult) {
const segments = [requestId, payResult.status, orderId || ''];
if (payResult.errorMessage) {
segments.push(encodeURIComponent(payResult.errorMessage));
}
return segments.join(':');
}
function notifyPreviousWebView(requestId, orderId, payResult) {
const result = buildPayResultValue(requestId, orderId, payResult);
wx.setStorageSync(PAY_RESULT_STORAGE_KEY, result);
const pages = getCurrentPages();
const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null;
if (previousPage && typeof previousPage.setData === 'function') {
previousPage.setData({
webViewUrl: appendPayResult(
previousPage.data.webViewUrl,
requestId,
status,
),
webViewUrl: appendPayResult(previousPage.data.webViewUrl, result),
});
}
}
@@ -151,6 +180,7 @@ function createWechatPayPage(pageContext) {
async onLoad(query) {
const requestId = String(query.requestId || '');
const orderId = String(query.orderId || '');
const payParams = parsePayParams(query.payParams);
if (!requestId || !payParams) {
const page = pageContext ?? this;
@@ -161,8 +191,8 @@ function createWechatPayPage(pageContext) {
return;
}
const status = await requestWechatPayment(payParams);
notifyPreviousWebView(requestId, status);
const payResult = await requestWechatPayment(payParams);
notifyPreviousWebView(requestId, orderId, payResult);
wx.navigateBack();
},
@@ -176,7 +206,9 @@ module.exports = {
canUseVirtualPayment,
PAY_RESULT_STORAGE_KEY,
appendPayResult,
buildPayResultValue,
createWechatPayPage,
normalizePayError,
parsePayParams,
safeCompareVersion,
requestWechatPayment,

View File

@@ -14,6 +14,7 @@ describe('wechat-pay mini program payment bridge', () => {
globalThis.wx = {
requestPayment: vi.fn(),
requestVirtualPayment: vi.fn(),
getSystemInfoSync: vi.fn(() => ({ SDKVersion: '2.32.0' })),
setStorageSync: vi.fn(),
navigateBack: vi.fn(),
};
@@ -32,9 +33,9 @@ describe('wechat-pay mini program payment bridge', () => {
signature: 'user-sig',
};
const status = await requestWechatPayment(payParams);
const result = await requestWechatPayment(payParams);
expect(status).toBe('success');
expect(result).toEqual({ status: 'success', errorMessage: '' });
expect(globalThis.wx.requestVirtualPayment).toHaveBeenCalledWith({
mode: 'short_series_coin',
signData: payParams.signData,
@@ -58,9 +59,9 @@ describe('wechat-pay mini program payment bridge', () => {
signature: 'user-sig',
};
const status = await requestWechatPayment(payParams);
const result = await requestWechatPayment(payParams);
expect(status).toBe('success');
expect(result).toEqual({ status: 'success', errorMessage: '' });
expect(globalThis.wx.requestVirtualPayment).toHaveBeenCalledWith({
mode: 'short_series_goods',
signData: payParams.signData,
@@ -77,7 +78,7 @@ describe('wechat-pay mini program payment bridge', () => {
options.success?.();
});
const status = await requestWechatPayment({
const result = await requestWechatPayment({
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay',
@@ -85,7 +86,7 @@ describe('wechat-pay mini program payment bridge', () => {
paySign: 'signature',
});
expect(status).toBe('success');
expect(result).toEqual({ status: 'success', errorMessage: '' });
expect(globalThis.wx.requestPayment).toHaveBeenCalledWith({
timeStamp: '1777110165',
nonceStr: 'nonce',
@@ -110,7 +111,13 @@ describe('wechat-pay mini program payment bridge', () => {
paySig: 'pay-sig',
signature: 'user-sig',
}),
).resolves.toBe('cancel');
).resolves.toEqual({
status: 'cancel',
errorMessage: JSON.stringify({
errCode: -2,
errMsg: 'requestVirtualPayment:fail cancel',
}),
});
});
test('page notifies previous web-view after virtual payment', async () => {
@@ -118,7 +125,7 @@ describe('wechat-pay mini program payment bridge', () => {
data: { webViewUrl: 'https://web.test/#tab=profile' },
setData: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => [{}, previousPage]);
globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]);
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
options.success?.({ errMsg: 'requestVirtualPayment:ok' });
});
@@ -128,6 +135,7 @@ describe('wechat-pay mini program payment bridge', () => {
await page.onLoad({
requestId: 'request-1',
orderId: 'order-1',
payParams: encodeURIComponent(
JSON.stringify({
mode: 'short_series_coin',
@@ -140,10 +148,10 @@ describe('wechat-pay mini program payment bridge', () => {
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
'genarrative:wechat-pay-result',
'request-1:success',
'request-1:success:order-1',
);
expect(previousPage.setData).toHaveBeenCalledWith({
webViewUrl: 'https://web.test/#tab=profile&wx_pay_result=request-1%3Asuccess',
webViewUrl: 'https://web.test/#tab=profile&wx_pay_result=request-1%3Asuccess%3Aorder-1',
});
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
});
@@ -152,8 +160,8 @@ describe('wechat-pay mini program payment bridge', () => {
expect(parsePayParams(encodeURIComponent('{"paySign":"sig"}'))).toEqual({
paySign: 'sig',
});
expect(appendPayResult('https://web.test/#old=1', 'req', 'fail')).toBe(
'https://web.test/#old=1&wx_pay_result=req%3Afail',
expect(appendPayResult('https://web.test/#old=1', 'req:fail:order-1')).toBe(
'https://web.test/#old=1&wx_pay_result=req%3Afail%3Aorder-1',
);
});
});

View File

@@ -17,6 +17,8 @@ type MiniProgramPage = {
data: Record<string, unknown>;
setData: (patch: Record<string, unknown>) => void;
onLoad: (query?: Record<string, string>) => Promise<void>;
onShow: () => void;
consumePayResult: () => void;
};
function createWxMock() {
@@ -44,6 +46,10 @@ function loadWebViewPage(
Page(config: Record<string, unknown>) {
pageConfig = config;
},
setTimeout(callback: () => void) {
callback();
return 1;
},
require(requestPath: string) {
if (requestPath === '../../config') {
return {
@@ -85,22 +91,40 @@ describe('mini-program web-view auth page', () => {
vi.clearAllMocks();
});
test('默认进入时直接打开 web-view,不触发微信登录请求', async () => {
test('默认进入时刷新微信小程序登录态后打开 web-view', async () => {
const wxMock = createWxMock();
wxMock.login.mockImplementation(({ success }) => {
success({ code: 'wx-login-code' });
});
wxMock.request.mockImplementation(({ success }) => {
success({
statusCode: 200,
data: {
token: 'jwt-active-wechat',
bindingStatus: 'active',
},
});
});
const page = loadWebViewPage(wxMock);
await page.onLoad({});
expect(wxMock.login).not.toHaveBeenCalled();
expect(wxMock.request).not.toHaveBeenCalled();
expect(wxMock.login).toHaveBeenCalledTimes(1);
expect(wxMock.request).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://www.genarrative.world/api/auth/wechat/miniprogram-login',
method: 'POST',
data: { code: 'wx-login-code' },
}),
);
expect(page.data.loading).toBe(false);
expect(page.data.phoneBindingRequired).toBe(false);
expect(page.data.webViewUrl).toBe(
'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program',
'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program#auth_provider=wechat&auth_token=jwt-active-wechat&auth_binding_status=active',
);
});
test('默认匿名进入 web-view 不依赖 API_BASE_URL 配置', async () => {
test('默认匿名进入 web-view 不依赖 API_BASE_URL 配置', async () => {
const wxMock = createWxMock();
const page = loadWebViewPage(wxMock, {
API_BASE_URL: '',
@@ -116,6 +140,27 @@ describe('mini-program web-view auth page', () => {
);
});
test('onShow 二次检查支付结果并写回 web-view hash', () => {
const wxMock = createWxMock();
wxMock.getStorageSync.mockImplementation((key) =>
key === 'genarrative:wechat-pay-result'
? 'request-1:success:order-1'
: '',
);
const page = loadWebViewPage(wxMock);
page.data.webViewUrl =
'https://www.genarrative.world/?clientType=mini_program#tab=profile';
page.onShow();
expect(wxMock.removeStorageSync).toHaveBeenCalledWith(
'genarrative:wechat-pay-result',
);
expect(page.data.webViewUrl).toBe(
'https://www.genarrative.world/?clientType=mini_program#tab=profile&wx_pay_result=request-1%3Asuccess%3Aorder-1',
);
});
test('H5 请求登录时才启动微信小程序登录并进入手机号授权态', async () => {
const wxMock = createWxMock();
wxMock.login.mockImplementation(({ success }) => {

View File

@@ -4,6 +4,7 @@ use axum::{
http::{HeaderMap, StatusCode},
response::Response,
};
use hmac::{Hmac, Mac};
use module_runtime::{
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5,
@@ -24,7 +25,6 @@ use module_runtime::{
};
use serde::Deserialize;
use serde_json::{Value, json};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use shared_contracts::runtime::{
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
@@ -1187,10 +1187,6 @@ fn build_wechat_virtual_pay_params(
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前微信登录态缺少 session_key请重新登录后再试")
})?;
let product = module_runtime::runtime_profile_recharge_product_by_id(&order.product_id)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值商品不存在")
})?;
let offer_id = required_wechat_virtual_payment_config(
state
.config
@@ -1198,7 +1194,7 @@ fn build_wechat_virtual_pay_params(
.as_deref(),
"微信虚拟支付 OfferId 未配置",
)?;
let mode = match product.kind {
let mode = match order.kind {
RuntimeProfileRechargeProductKind::Points => "short_series_coin",
RuntimeProfileRechargeProductKind::Membership => "short_series_goods",
};
@@ -1215,9 +1211,9 @@ fn build_wechat_virtual_pay_params(
"openId": openid,
}).to_string(),
});
if product.kind == RuntimeProfileRechargeProductKind::Membership {
sign_data["productId"] = json!(product.product_id);
sign_data["goodsPrice"] = json!(product.price_cents);
if order.kind == RuntimeProfileRechargeProductKind::Membership {
sign_data["productId"] = json!(order.product_id);
sign_data["goodsPrice"] = json!(order.amount_cents);
}
let sign_data = sign_data.to_string();
let pay_sig = calc_wechat_virtual_payment_signature(state, &sign_data, false)?;
@@ -1242,7 +1238,10 @@ fn calc_wechat_virtual_payment_signature(
.config
.wechat_mini_program_virtual_payment_sandbox_app_key
.as_deref()
.or(state.config.wechat_mini_program_virtual_payment_app_key.as_deref())
.or(state
.config
.wechat_mini_program_virtual_payment_app_key
.as_deref())
} else {
state
.config
@@ -1250,8 +1249,7 @@ fn calc_wechat_virtual_payment_signature(
.as_deref()
}
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("微信虚拟支付 AppKey 未配置")
AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信虚拟支付 AppKey 未配置")
})?;
calc_wechat_virtual_payment_signature_with_key(app_key, sign_data)
}
@@ -2294,6 +2292,59 @@ mod tests {
assert!(!params.signature.is_empty());
}
#[tokio::test]
async fn wechat_virtual_pay_params_accept_admin_membership_product_ids() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
wechat_mini_program_virtual_payment_env: 0,
..fast_spacetime_timeout_config()
})
.await;
let wechat_login = state
.wechat_auth_service()
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "openid-user-item01".to_string(),
provider_union_id: Some("union-user-item01".to_string()),
display_name: Some("资料页用户".to_string()),
avatar_url: None,
session_key: Some("session-key-item01".to_string()),
},
})
.await
.expect("wechat identity should seed");
let order = RuntimeProfileRechargeOrderRecord {
order_id: "item01order01".to_string(),
user_id: wechat_login.user.id,
product_id: "item01".to_string(),
product_title: "测试道具".to_string(),
kind: RuntimeProfileRechargeProductKind::Membership,
amount_cents: 100,
status: RuntimeProfileRechargeOrderStatus::Pending,
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
.to_string(),
paid_at: None,
paid_at_micros: None,
provider_transaction_id: None,
created_at: "2026-05-27T10:00:00Z".to_string(),
created_at_micros: 1_779_842_400_000_000,
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
};
let params = build_wechat_virtual_pay_params(&state, &order, "openid-user-item01")
.expect("custom membership virtual pay params should build");
let sign_data: Value =
serde_json::from_str(&params.sign_data).expect("sign data should be valid json");
assert_eq!(params.mode, "short_series_goods");
assert_eq!(sign_data["productId"], "item01");
assert_eq!(sign_data["goodsPrice"], 100);
assert_eq!(sign_data["outTradeNo"], "item01order01");
}
#[tokio::test]
async fn profile_feedback_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));

View File

@@ -2081,7 +2081,7 @@ function pickDraftCompletionDialogSourceId(
function buildDraftCompletionDialogSource(
kind: CreationWorkShelfKind,
ids: Array<string | null | undefined>,
) {
): string {
const sourceId = pickDraftCompletionDialogSourceId(ids);
switch (kind) {
case 'rpg':
@@ -2103,6 +2103,7 @@ function buildDraftCompletionDialogSource(
case 'baby-object-match':
return formatPlatformTaskCompletionSource('宝贝识物草稿', sourceId);
}
return formatPlatformTaskCompletionSource('创作草稿', sourceId);
}
function createMiniGameDraftGenerationStateForRestoredDraft(

View File

@@ -1417,8 +1417,9 @@ test('profile recharge modal posts virtual payment params in mini program web-vi
'requestId',
);
expect(requestId).toBeTruthy();
expect(screen.getByRole('dialog', { name: '正在支付' })).toBeTruthy();
act(() => {
window.location.hash = `wx_pay_result=${requestId}:success`;
window.location.hash = `wx_pay_result=${requestId}:success:order-wechat-1`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
expect(navigateUrl).toContain('order-wechat-1');
@@ -1512,6 +1513,87 @@ test('profile recharge modal posts membership goods virtual payment params in mi
expect(decodeURIComponent(navigateUrl)).toContain('"paySig":"pay-sig"');
});
test('profile recharge modal releases submitting state and shows virtual payment failure detail', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
window.wx = {
miniProgram: {
navigateTo,
},
};
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-sandbox-fail',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
paidAt: null as string | null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":1,"currencyType":"CNY","outTradeNo":"order-wechat-sandbox-fail","attach":"mud_points_60"}',
paySig: 'sandbox-pay-sig',
signature: 'user-sig',
},
});
renderProfileView();
await openRechargeModal(user);
const buyButton = await screen.findByRole('button', { name: /60/u });
await user.click(buyButton);
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
'requestId',
);
expect(requestId).toBeTruthy();
act(() => {
window.location.hash = `wx_pay_result=${requestId}:fail:order-wechat-sandbox-fail:${encodeURIComponent('{"errCode":-1,"errMsg":"requestVirtualPayment:fail sandbox"}')}`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
expect(
await screen.findByRole('dialog', { name: '支付未完成' }),
).toBeTruthy();
expect(
screen.getByText(/requestVirtualPayment:fail sandbox/u),
).toBeTruthy();
await waitFor(() => {
expect(
within(screen.getByRole('button', { name: /60/u })).getByText(
'购买',
{ selector: 'span' },
),
).toBeTruthy();
});
});
test('profile recharge modal waits for paid confirmation before refreshing dashboard', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();

View File

@@ -329,6 +329,7 @@ type WechatPayResult = {
requestId: string;
orderId: string | null;
status: WechatMiniProgramPaymentStatus;
errorMessage: string | null;
};
type RechargePaymentResultKind = 'success' | 'pending' | 'cancel' | 'failed';
type RechargePaymentResult = {
@@ -2681,22 +2682,34 @@ function readWechatPayResultFromHash(): WechatPayResult | null {
return null;
}
const [requestId = '', rawStatus = ''] = result.split(':');
const orderId = requestId
const [requestId = '', rawStatus = '', explicitOrderId = '', ...rawErrors] =
result.split(':');
const inferredOrderId = requestId
.replace(/^wechat_pay_/, '')
.replace(/_\d+$/, '')
.trim();
const orderId = explicitOrderId.trim() || inferredOrderId;
const status =
rawStatus === 'success'
? 'success'
: rawStatus === 'cancel'
? 'cancel'
: 'fail';
let errorMessage: string | null = null;
const rawError = rawErrors.join(':');
if (rawError) {
try {
errorMessage = decodeURIComponent(rawError);
} catch (_error) {
errorMessage = rawError;
}
}
return {
requestId,
orderId: orderId || null,
status,
errorMessage,
};
}
@@ -4586,6 +4599,7 @@ export function RpgEntryHomeView({
return;
}
setSubmittingRechargeProductId(null);
if (payResult.status === 'success') {
setRechargePaymentResult({
kind: 'pending',
@@ -4635,10 +4649,13 @@ export function RpgEntryHomeView({
});
refreshRechargeState();
} else {
const detail = payResult.errorMessage
? `微信返回:${payResult.errorMessage}`
: '微信支付没有完成,本次不会入账。';
setRechargePaymentResult({
kind: 'failed',
title: '支付未完成',
message: '微信支付没有完成,本次不会入账。',
message: detail,
});
refreshRechargeState();
}
@@ -4679,11 +4696,16 @@ export function RpgEntryHomeView({
.then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
setRechargePaymentResult({
kind: 'pending',
title: '正在支付',
message: '请在微信小程序支付页完成支付,返回后会自动刷新状态。',
});
await requestWechatMiniProgramPayment(
response.wechatMiniProgramPayParams,
response.order.orderId,
);
setRechargeCenter(response.center);
return;
}
if (paymentChannel === WECHAT_H5_PAYMENT_CHANNEL) {

View File

@@ -9,6 +9,7 @@ export default defineConfig({
'src/**/*.test.tsx',
'apps/admin-web/src/**/*.test.ts',
'apps/admin-web/src/**/*.test.tsx',
'miniprogram/**/*.test.js',
'scripts/**/*.test.ts',
'packages/shared/src/**/*.test.ts',
],