| | |
| | | /** |
| | | * 微赞 H5 唤起小程序支付桥接。 |
| | | * 处理 openWebPay 原生回调并拉起微信小程序支付页。 |
| | | * |
| | | * H5 下单时通过原生 JSBridge 调用 APP 暴露的钩子方法: |
| | | * - Android: window.android.jumpToMiniProgramPay(JSON.stringify(params)) |
| | | * - iOS: window.webkit.messageHandlers.jumpToMiniProgramPay.postMessage(JSON.stringify(params)) |
| | | * |
| | | * params 字段(由微赞 H5 透传): |
| | | * - orderId: 订单号(用于日志/埋点,不参与拉起小程序) |
| | | * - path: 小程序支付页路径 |
| | | * - username: 小程序原始 ID(gh_ 开头) |
| | | * - miniprogramType:小程序类型,固定 0 |
| | | * |
| | | * 由 APP 用 plus.share.launchMiniProgram 拉起微信小程序完成支付(仅易宝支付)。 |
| | | * 微信 appId: wx074c1f0cf5a0ecf4(见 manifest.json app-plus.distribute.sdkConfigs)。 |
| | | * @param {Object|string} result openWebPay 回调参数 |
| | | */ |
| | | |
| | | const PAY_BRIDGE_NAME = 'jumpToMiniProgramPay' |
| | | const PAY_BRIDGE_ANDROID_KEY = 'android' // H5 调 window.android.xxx |
| | | const LOG_TAG = '[weizanPay]' |
| | | |
| | | /** |
| | | * 给指定 webview 注册微赞支付 JSBridge。 |
| | | * 仅在 APP-PLUS 环境生效;非 APP 环境(H5/小程序)静默 no-op。 |
| | | * |
| | | * @param {Object} webviewInstance plus.webview 实例(plus.webview.currentWebview() 拿到) |
| | | * @param {Function} [onPay] 收到 H5 回调时透传 (params),可在此做埋点/校验,不影响唤起 |
| | | */ |
| | | export function registerWeizanPayBridge(webviewInstance, onPay) { |
| | | export function jumpToMiniProgramPay(result) { |
| | | // #ifdef APP-PLUS |
| | | if (!webviewInstance) { |
| | | return |
| | | } |
| | | const platform = String(uni.getSystemInfoSync().platform || '').toLowerCase() |
| | | if (platform === 'android') { |
| | | registerAndroidPayBridge(webviewInstance, onPay) |
| | | } else if (platform === 'ios') { |
| | | registerIOSPayBridge(webviewInstance, onPay) |
| | | } |
| | | // #endif |
| | | } |
| | | |
| | | /** |
| | | * 反注册微赞支付 JSBridge,避免 webview 复用导致的内存泄漏与重复回调。 |
| | | * |
| | | * @param {Object} webviewInstance plus.webview 实例 |
| | | */ |
| | | export function unregisterWeizanPayBridge(webviewInstance) { |
| | | // #ifdef APP-PLUS |
| | | if (!webviewInstance) { |
| | | return |
| | | } |
| | | try { |
| | | const platform = String(uni.getSystemInfoSync().platform || '').toLowerCase() |
| | | if (platform === 'android') { |
| | | // Android 移除注入的 JS 接口(同样要作用在原生 WebView 上) |
| | | try { |
| | | const nativeWebView = getAndroidNativeWebView(webviewInstance) |
| | | if (nativeWebView) { |
| | | plus.android.invoke(nativeWebView, 'removeJavascriptInterface', PAY_BRIDGE_ANDROID_KEY) |
| | | } |
| | | } catch (e) {} |
| | | } |
| | | // iOS:WKUserContentController 的 messageHandler 通常随 webview 销毁自动回收, |
| | | // 显式 removeScriptMessageHandlerForName 在 plus.ios 上不一定支持,这里不强行调用 |
| | | } catch (e) { |
| | | console.error('[weizanPay] unregister fail:', e) |
| | | } |
| | | // #endif |
| | | } |
| | | |
| | | /** |
| | | * 钩子方法:唤起微信小程序完成支付。 |
| | | * orderId / path / username / miniprogramType 由微赞 H5 回调时传入。 |
| | | * |
| | | * @param {Object} params { orderId, path, username, miniprogramType } |
| | | */ |
| | | export function jumpToMiniProgramPay(params) { |
| | | // #ifdef APP-PLUS |
| | | console.log('[weizanPay] >>> jumpToMiniProgramPay 被调用, 原始入参:', params, '类型:', typeof params) |
| | | const safeParams = normalizePayParams(params) |
| | | console.log('[weizanPay] 归一化后参数:', JSON.stringify(safeParams)) |
| | | if (!safeParams.username) { |
| | | console.warn('[weizanPay] username 缺失,终止唤起。safeParams=', JSON.stringify(safeParams)) |
| | | console.log('[weizanPay] openWebPay 原生回调:', result) |
| | | const params = normalizePayParams(result) |
| | | if (!params.username || !params.path) { |
| | | console.error('[weizanPay] 小程序支付参数缺失:', params) |
| | | uni.showToast({ title: '支付参数缺失', icon: 'none' }) |
| | | return |
| | | } |
| | | console.log('[weizanPay] 参数校验通过,开始调用 plus.share.getServices 唤起小程序') |
| | | |
| | | plus.share.getServices((services) => { |
| | | const wx = services && services.find((s) => s && s.id === 'weixin') |
| | | const wx = (services || []).find((service) => service && service.id === 'weixin') |
| | | if (!wx) { |
| | | uni.showToast({ title: '当前环境不支持微信', icon: 'none' }) |
| | | return |
| | |
| | | uni.showToast({ title: '请先安装微信客户端', icon: 'none' }) |
| | | return |
| | | } |
| | | |
| | | wx.launchMiniProgram({ |
| | | id: safeParams.username, |
| | | path: safeParams.path || '', |
| | | type: Number(safeParams.miniprogramType) || 0, |
| | | id: params.username, |
| | | path: params.path, |
| | | type: params.miniprogramType, |
| | | }, () => { |
| | | console.log('[weizanPay] 唤起小程序成功, orderId=', safeParams.orderId) |
| | | }, (err) => { |
| | | console.error('[weizanPay] 唤起失败', err) |
| | | const errMsg = String(err && (err.message || err.errMsg) || '') |
| | | console.log('[weizanPay] 小程序支付页拉起成功, orderId:', params.orderId) |
| | | }, (error) => { |
| | | console.error('[weizanPay] 小程序支付页拉起失败:', error) |
| | | const message = String(error && (error.message || error.errMsg) || '') |
| | | uni.showToast({ |
| | | title: errMsg.indexOf('badprogram') >= 0 ? '请检查小程序原始ID及关联配置' : '唤起支付失败', |
| | | title: message.includes('badprogram') ? '请检查小程序原始ID及关联配置' : '唤起支付失败', |
| | | icon: 'none', |
| | | }) |
| | | }) |
| | | }, (err) => { |
| | | console.error('[weizanPay] 微信服务不可用', err) |
| | | }, (error) => { |
| | | console.error('[weizanPay] 获取微信服务失败:', error) |
| | | uni.showToast({ title: '微信服务不可用', icon: 'none' }) |
| | | }) |
| | | // #endif |
| | | } |
| | | |
| | | // ===== Android 实现:注入 window.android.jumpToMiniProgramPay ===== |
| | | function registerAndroidPayBridge(webviewInstance, onPay) { |
| | | console.log(LOG_TAG, '[Android] 开始注册 JSBridge,注入 key=', PAY_BRIDGE_ANDROID_KEY, '方法名=', PAY_BRIDGE_NAME) |
| | | console.log(LOG_TAG, '[Android] webviewInstance=', webviewInstance ? '[Object]' : 'null') |
| | | // plus.webview.create 返回的是 JS 包装对象,不能直接反射; |
| | | // 必须先取底层原生 WebView(5+ ADWebView,是 android.webkit.WebView 的子类)再反射, |
| | | // 否则 getSettings / addJavascriptInterface 都会失败。 |
| | | const nativeWebView = getAndroidNativeWebView(webviewInstance) |
| | | console.log(LOG_TAG, '[Android] nativeInstanceObject=', nativeWebView ? '[Object]' : 'null') |
| | | // 反射验证是否是真原生 WebView(应暴露 getSettings) |
| | | const settings = safeAndroidInvoke(nativeWebView, 'getSettings') |
| | | console.log(LOG_TAG, '[Android] getSettings 反射结果=', settings ? '[Object]' : 'null') |
| | | if (!settings) { |
| | | console.error(LOG_TAG, '[Android] 未取到原生 WebView 或不暴露 getSettings,注册中止') |
| | | console.error(LOG_TAG, '[Android] 请确认调用方传入的是 plus.webview.create 创建的子 webview,而不是 currentWebview()') |
| | | return |
| | | } |
| | | try { |
| | | // java.io.Serializable 一定存在、无强制方法,足以承载动态代理 |
| | | // H5 调 window.android.xxx 时由 Proxy 拦截转发到 JS 函数 |
| | | const JsInterface = plus.android.implements('java.io.Serializable', { |
| | | [PAY_BRIDGE_NAME]: function (paramsJson) { |
| | | console.log(LOG_TAG, '===== [Android] H5 调用 window.android.' + PAY_BRIDGE_NAME + ' =====') |
| | | console.log(LOG_TAG, '[Android] 原始 paramsJson=', safeStringify(paramsJson)) |
| | | // JS 接口回调在原生子线程,必须切回 UI 线程才能拉起微信 SDK |
| | | plus.android.runOnUiThread(() => { |
| | | console.log(LOG_TAG, '[Android] 已进入 UI 线程,开始解析参数') |
| | | const params = normalizePayParams(parseJsonSafe(paramsJson)) |
| | | console.log(LOG_TAG, '[Android] 解析后 params=', safeStringify(params)) |
| | | if (typeof onPay === 'function') { |
| | | try { onPay(params) } catch (e) {} |
| | | } |
| | | jumpToMiniProgramPay(params) |
| | | }) |
| | | } |
| | | }) |
| | | console.log(LOG_TAG, '[Android] JsInterface 已创建:', JsInterface ? '[Object]' : 'null') |
| | | // 注入到原生 WebView(而非 JS 包装对象),H5 端才能拿到 window.android.xxx |
| | | plus.android.invoke(nativeWebView, 'addJavascriptInterface', JsInterface, PAY_BRIDGE_ANDROID_KEY) |
| | | console.log(LOG_TAG, '[Android] addJavascriptInterface 完成,window.' + PAY_BRIDGE_ANDROID_KEY + ' 已注入') |
| | | // 通过 evalJS 验证 H5 端 window.android 是否真的能访问到 |
| | | try { |
| | | if (typeof webviewInstance.evalJS === 'function') { |
| | | webviewInstance.evalJS(` |
| | | try { |
| | | if (typeof window.android === 'undefined' || window.android === null) { |
| | | console.log('[weizanPay][H5] window.android 不存在'); |
| | | } else { |
| | | console.log('[weizanPay][H5] window.android 存在, keys=' + Object.keys(window.android).join(',')); |
| | | console.log('[weizanPay][H5] window.android.${PAY_BRIDGE_NAME} 类型=' + typeof window.android.${PAY_BRIDGE_NAME}); |
| | | } |
| | | } catch (e) { |
| | | console.log('[weizanPay][H5] 检查 window.android 异常: ' + e.message); |
| | | } |
| | | `) |
| | | } |
| | | } catch (e) { |
| | | console.warn(LOG_TAG, '[Android] evalJS 验证失败:', e && e.message) |
| | | } |
| | | } catch (e) { |
| | | console.error(LOG_TAG, '[Android] 注册 JSBridge 失败:', e) |
| | | console.error(LOG_TAG, '[Android] 异常 message=', e && e.message) |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 取 plus.webview 实例底层的原生 android.webkit.WebView。 |
| | | * plus.webview.create 返回的是 JS 包装对象,直接反射 getSettings/addJavascriptInterface 会失败, |
| | | * 必须先取到原生 WebView。优先用官方 nativeInstanceObject(),失败再退回 obtainWebview()。 |
| | | * 兼容原生插件返回对象或 JSON 字符串。 |
| | | */ |
| | | function getAndroidNativeWebView(webview) { |
| | | if (!webview) { |
| | | return null |
| | | } |
| | | // 1. 官方 API:WebviewObject.nativeInstanceObject() 返回原生 WebView |
| | | if (typeof webview.nativeInstanceObject === 'function') { |
| | | function normalizePayParams(result) { |
| | | let source = result |
| | | if (typeof result === 'string') { |
| | | try { |
| | | const nativeObj = webview.nativeInstanceObject() |
| | | if (nativeObj) { |
| | | return nativeObj |
| | | } |
| | | } catch (e) { |
| | | console.warn(LOG_TAG, '[Android] nativeInstanceObject 调用失败:', e && e.message) |
| | | source = JSON.parse(result) |
| | | } catch (error) { |
| | | console.error('[weizanPay] 回调参数不是有效 JSON:', error) |
| | | source = {} |
| | | } |
| | | } |
| | | // 2. 兼容:部分运行时通过 obtainWebview 取原生 WebView |
| | | if (typeof webview.obtainWebview === 'function') { |
| | | try { |
| | | const obtained = webview.obtainWebview() |
| | | if (obtained) { |
| | | return obtained |
| | | } |
| | | } catch (e) {} |
| | | if (!source || typeof source !== 'object') { |
| | | source = {} |
| | | } |
| | | return null |
| | | } |
| | | |
| | | /** |
| | | * 安全调用 plus.android.invoke,失败返回 null。 |
| | | */ |
| | | function safeAndroidInvoke(obj, methodName, ...args) { |
| | | try { |
| | | return plus.android.invoke(obj, methodName, ...args) |
| | | } catch (e) { |
| | | return null |
| | | } |
| | | } |
| | | |
| | | // ===== iOS 实现:注册 window.webkit.messageHandlers.jumpToMiniProgramPay ===== |
| | | function registerIOSPayBridge(webviewInstance, onPay) { |
| | | console.log(LOG_TAG, '[iOS] 开始注册 JSBridge,handler name=', PAY_BRIDGE_NAME) |
| | | console.log(LOG_TAG, '[iOS] webviewInstance=', webviewInstance ? '[Object]' : 'null') |
| | | try { |
| | | // 实现 WKScriptMessageHandler 协议 |
| | | const MessageHandler = plus.ios.implements('NSObject<WKScriptMessageHandler>', { |
| | | 'userContentController:didReceiveScriptMessage:': function (controller, message) { |
| | | console.log(LOG_TAG, '===== [iOS] didReceiveScriptMessage 被调用 =====') |
| | | if (!message) { |
| | | console.warn(LOG_TAG, '[iOS] message 为空,跳过') |
| | | return |
| | | } |
| | | // message.name === 'jumpToMiniProgramPay',message.body 是 H5 传的参数 |
| | | let raw = message |
| | | try { |
| | | const body = plus.ios.invoke(message, 'body') |
| | | console.log(LOG_TAG, '[iOS] message.body=', safeStringify(body)) |
| | | if (body !== undefined && body !== null) { |
| | | raw = body |
| | | } |
| | | } catch (e) {} |
| | | console.log(LOG_TAG, '[iOS] 原始 raw=', safeStringify(raw)) |
| | | const params = normalizePayParams(parseJsonSafe(raw)) |
| | | console.log(LOG_TAG, '[iOS] 解析后 params=', safeStringify(params)) |
| | | if (typeof onPay === 'function') { |
| | | try { onPay(params) } catch (e) {} |
| | | } |
| | | jumpToMiniProgramPay(params) |
| | | } |
| | | }) |
| | | console.log(LOG_TAG, '[iOS] MessageHandler 已创建:', MessageHandler ? '[Object]' : 'null') |
| | | // 取底层 WKWebView:plus.webview 实例 → valueForKey('webView') 或直接复用 |
| | | console.log(LOG_TAG, '[iOS] 尝试 valueForKey:webView') |
| | | const wkWebView = safeIosInvoke(webviewInstance, 'valueForKey:', 'webView') || webviewInstance |
| | | console.log(LOG_TAG, '[iOS] wkWebView=', wkWebView ? '[Object]' : 'null') |
| | | const configuration = safeIosInvoke(wkWebView, 'configuration') |
| | | console.log(LOG_TAG, '[iOS] configuration=', configuration ? '[Object]' : 'null') |
| | | if (!configuration) { |
| | | console.error(LOG_TAG, '[iOS] 未拿到 WKWebView configuration,注册中止') |
| | | console.error(LOG_TAG, '[iOS] 请确认传入的是 plus.webview.create 创建的子 webview') |
| | | return |
| | | } |
| | | const userContentController = safeIosInvoke(configuration, 'userContentController') |
| | | console.log(LOG_TAG, '[iOS] userContentController=', userContentController ? '[Object]' : 'null') |
| | | if (!userContentController) { |
| | | console.error(LOG_TAG, '[iOS] 未拿到 userContentController,注册中止') |
| | | return |
| | | } |
| | | plus.ios.invoke(userContentController, 'addScriptMessageHandler:name:', MessageHandler, PAY_BRIDGE_NAME) |
| | | console.log(LOG_TAG, '[iOS] addScriptMessageHandler 完成,window.webkit.messageHandlers.' + PAY_BRIDGE_NAME + ' 已注册') |
| | | // 验证 H5 端是否能访问到 messageHandlers |
| | | try { |
| | | if (typeof webviewInstance.evalJS === 'function') { |
| | | webviewInstance.evalJS(` |
| | | try { |
| | | if (!window.webkit || !window.webkit.messageHandlers) { |
| | | console.log('[weizanPay][H5] window.webkit.messageHandlers 不存在'); |
| | | } else { |
| | | var names = Object.keys(window.webkit.messageHandlers); |
| | | console.log('[weizanPay][H5] messageHandlers names=' + names.join(',')); |
| | | var handler = window.webkit.messageHandlers.${PAY_BRIDGE_NAME}; |
| | | console.log('[weizanPay][H5] ${PAY_BRIDGE_NAME} handler 类型=' + typeof handler); |
| | | } |
| | | } catch (e) { |
| | | console.log('[weizanPay][H5] 检查 messageHandlers 异常: ' + e.message); |
| | | } |
| | | `) |
| | | } |
| | | } catch (e) { |
| | | console.warn(LOG_TAG, '[iOS] evalJS 验证失败:', e && e.message) |
| | | } |
| | | } catch (e) { |
| | | console.error(LOG_TAG, '[iOS] 注册 JSBridge 失败:', e) |
| | | console.error(LOG_TAG, '[iOS] 异常 message=', e && e.message) |
| | | } |
| | | } |
| | | |
| | | // ===== 辅助函数 ===== |
| | | |
| | | /** |
| | | * 安全序列化任意值,用于日志打印。原生对象、循环引用、undefined 都不会抛错。 |
| | | */ |
| | | function safeStringify(value) { |
| | | try { |
| | | if (value === undefined) { |
| | | return 'undefined' |
| | | } |
| | | if (value === null) { |
| | | return 'null' |
| | | } |
| | | if (typeof value === 'string') { |
| | | return value |
| | | } |
| | | if (typeof value === 'number' || typeof value === 'boolean') { |
| | | return String(value) |
| | | } |
| | | if (typeof value === 'function') { |
| | | return '[Function]' |
| | | } |
| | | return JSON.stringify(value) |
| | | } catch (e) { |
| | | try { |
| | | return String(value) |
| | | } catch (e2) { |
| | | return '[Unserializable]' |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 把 H5 传过来的 params 归一化为标准结构。 |
| | | * 同时兼容下划线命名(order_id)与驼峰命名(orderId)。 |
| | | * |
| | | * @param {Object|string} input |
| | | * @returns {{ orderId: string, path: string, username: string, miniprogramType: number }} |
| | | */ |
| | | function normalizePayParams(input) { |
| | | let params = {} |
| | | if (input && typeof input === 'object') { |
| | | params = input |
| | | } else if (typeof input === 'string') { |
| | | params = parseJsonSafe(input) |
| | | } |
| | | const miniProgramType = Number(source.miniprogramType) |
| | | return { |
| | | orderId: String(params.orderId || params.order_id || ''), |
| | | path: String(params.path || ''), |
| | | username: String(params.username || params.userName || params.user_name || ''), |
| | | miniprogramType: Number.isFinite(Number(params.miniprogramType)) ? Number(params.miniprogramType) : 0, |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 安全解析 JSON 字符串,失败返回空对象。 |
| | | */ |
| | | function parseJsonSafe(text) { |
| | | if (typeof text !== 'string') { |
| | | return text || {} |
| | | } |
| | | try { |
| | | return JSON.parse(text) |
| | | } catch (e) { |
| | | return {} |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 安全调用 plus.ios.invoke,失败返回 null。 |
| | | */ |
| | | function safeIosInvoke(obj, selector, ...args) { |
| | | try { |
| | | return plus.ios.invoke(obj, selector, ...args) |
| | | } catch (e) { |
| | | return null |
| | | username: String(source.username || ''), |
| | | path: String(source.path || ''), |
| | | miniprogramType: Number.isFinite(miniProgramType) ? miniProgramType : 0, |
| | | orderId: String(source.orderId || ''), |
| | | } |
| | | } |