fix wechat mini program virtual payment flow
This commit is contained in:
@@ -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 与本地 storage;WebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。
|
||||
- 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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(¶ms.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"));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user