diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index afe1ff24..c5fdf9ed 100644 --- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -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、签名和基础库能力问题。 diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index c199300b..1f33e2cb 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -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); diff --git a/miniprogram/pages/wechat-pay/index.shared.js b/miniprogram/pages/wechat-pay/index.shared.js index cdf73eb3..d35b3c7d 100644 --- a/miniprogram/pages/wechat-pay/index.shared.js +++ b/miniprogram/pages/wechat-pay/index.shared.js @@ -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, diff --git a/miniprogram/pages/wechat-pay/index.test.js b/miniprogram/pages/wechat-pay/index.test.js index f4096260..e4520aa5 100644 --- a/miniprogram/pages/wechat-pay/index.test.js +++ b/miniprogram/pages/wechat-pay/index.test.js @@ -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', ); }); }); diff --git a/scripts/miniprogram-web-view-auth.test.ts b/scripts/miniprogram-web-view-auth.test.ts index 108d2a5f..388677e0 100644 --- a/scripts/miniprogram-web-view-auth.test.ts +++ b/scripts/miniprogram-web-view-auth.test.ts @@ -17,6 +17,8 @@ type MiniProgramPage = { data: Record; setData: (patch: Record) => void; onLoad: (query?: Record) => Promise; + onShow: () => void; + consumePayResult: () => void; }; function createWxMock() { @@ -44,6 +46,10 @@ function loadWebViewPage( Page(config: Record) { 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 }) => { diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index d604bc18..effe4f8d 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -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")); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 48a1278c..301342ea 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -2081,7 +2081,7 @@ function pickDraftCompletionDialogSourceId( function buildDraftCompletionDialogSource( kind: CreationWorkShelfKind, ids: Array, -) { +): 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( diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 4c226935..4fbd15a3 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -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(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index ba2b26f9..8bb452ad 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -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) { diff --git a/vitest.config.ts b/vitest.config.ts index 9a44f493..87ae74b8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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', ],