quling
2026-08-24 676f4f81dd1b24b12b21f68ed3f2c381b520c0bf
feat0806: 微赞回调
1个文件已添加
3个文件已修改
445 ■■■■■ 已修改文件
traditionalApp/pages.json 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/home/home.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/weizanLive/weizanLive.vue 252 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/weizan-pay-bridge.js 185 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages.json
@@ -170,6 +170,12 @@
            }
        },
        {
            "path": "pages/weizanLive/weizanLive",
            "style": {
                "navigationBarTitleText": "微赞直播"
            }
        },
        {
            "path": "pages/feedback/feedback",
            "style": {
                "navigationStyle": "custom"
traditionalApp/pages/home/home.vue
@@ -379,7 +379,7 @@
                return;
            }
            const webviewUrl = `/pages/webview/webview?url=${encodeURIComponent(targetUrl)}&title=${encodeURIComponent('微赞直播')}&clearCache=1`;
            const webviewUrl = `/pages/weizanLive/weizanLive?url=${encodeURIComponent(targetUrl)}&title=${encodeURIComponent('微赞直播')}`;
            console.log('[goWeiZanLive] 准备跳转 WebView:', webviewUrl);
            uni.navigateTo({
traditionalApp/pages/weizanLive/weizanLive.vue
New file
@@ -0,0 +1,252 @@
<template>
    <view class="weizan-live-container">
        <!-- 子 webview 由 plus.webview.create 命令式创建并浮在该 view 之上,无需在 template 中渲染 -->
    </view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onUnload, onShow, onHide } from '@dcloudio/uni-app'
import { clearWebViewData } from '@/utils/webview-cache.js'
import { registerWeizanPayBridge, unregisterWeizanPayBridge } from '@/utils/weizan-pay-bridge.js'
const WEIZAN_WEBVIEW_ID = 'weizanLiveWebview'
// 微赞识别易宝支付通道用的 UA 后缀(接入方案文档约定)。
// 必须完整携带以下标识,微赞 H5 才会把当前环境判定为 isVzanApp / 成功注册 AppChannel,
// 进而回调 window.android.jumpToMiniProgramPay:
//   webchatPay/wx...                → 易宝支付通道
//   InAppPay Vzan/miniAppPayment    → 微赞 APP 内小程序支付
//   vzanApp / zhiboApp              → 微赞 APP / 直播 APP 标识
const WEIZAN_UA_SUFFIX = 'webchatPay/wx074c1f0cf5a0ecf4 InAppPay Vzan/miniAppPayment vzanApp navigator.userAgent zhiboApp'
let subWebview = null
let currentUrl = ''
let currentTitle = ''
const loadError = ref('')
/**
 * 解码 query 参数。
 */
function decodeQueryValue(value) {
    if (!value) {
        return ''
    }
    try {
        return decodeURIComponent(value)
    } catch (e) {
        return value || ''
    }
}
/**
 * 页面加载:读取 URL/Title,进入前清缓存,创建子 webview 并注册支付桥。
 */
onLoad((options) => {
    currentUrl = decodeQueryValue(options?.url)
    currentTitle = decodeQueryValue(options?.title)
    console.log('[weizanLive] onLoad, url=', currentUrl, 'title=', currentTitle)
    if (currentTitle) {
        uni.setNavigationBarTitle({ title: currentTitle })
    }
    // #ifdef APP-PLUS
    // 进入前先清理上次缓存、cookie、登录态
    clearWebViewData()
    // 创建子 webview(注入 UA + 注册支付桥 + 加载 URL)
    createWeizanWebview(currentUrl)
    // #endif
})
/**
 * 页面卸载:反注册支付桥并关闭子 webview,避免残留。
 */
onUnload(() => {
    console.log('[weizanLive] onUnload')
    // #ifdef APP-PLUS
    if (subWebview) {
        try {
            unregisterWeizanPayBridge(subWebview)
        } catch (e) {
            console.warn('[weizanLive] unregisterWeizanPayBridge fail:', e)
        }
        try {
            subWebview.close()
        } catch (e) {
            console.warn('[weizanLive] subWebview.close fail:', e)
        }
        subWebview = null
    }
    // #endif
})
onShow(() => {
    // #ifdef APP-PLUS
    if (subWebview) {
        try {
            subWebview.show()
        } catch (e) {}
    }
    // #endif
})
onHide(() => {
    // #ifdef APP-PLUS
    if (subWebview) {
        try {
            subWebview.hide()
        } catch (e) {}
    }
    // #endif
})
/**
 * 创建子 webview:先创建空白实例 → 注入 UA → 注册支付桥 → 加载真实 URL → show。
 * 这个顺序保证 H5 加载完成时 UA 已带上微赞标识、window.android.jumpToMiniProgramPay 已可用。
 */
function createWeizanWebview(targetUrl) {
    // #ifdef APP-PLUS
    const platform = String(uni.getSystemInfoSync().platform || '').toLowerCase()
    console.log('[weizanLive] createWeizanWebview, platform=', platform, 'url=', targetUrl)
    try {
        // 1. 创建空白 webview(不立即加载 URL)
        subWebview = plus.webview.create('', WEIZAN_WEBVIEW_ID, {
            top: '0px',
            bottom: '0px',
            background: '#FFFFFF',
            // 让子 webview 的 progress 进度条颜色与 APP 一致
            progress: {
                color: '#FF3333'
            }
        })
        console.log('[weizanLive] plus.webview.create 完成, subWebview=', subWebview ? '[Object]' : 'null')
        // 2. 注入 UA(让微赞 H5 识别为易宝支付通道)
        injectUserAgent(subWebview, platform)
        // 3. 注册支付 JSBridge(在 loadURL 之前注册,保证 H5 加载完成时钩子已可用)
        registerWeizanPayBridge(subWebview)
        // 4. 加载真实 URL
        if (targetUrl) {
            subWebview.loadURL(targetUrl)
            console.log('[weizanLive] loadURL 调用完成:', targetUrl)
        } else {
            loadError.value = '直播间地址为空'
        }
        // 5. 显示子 webview
        subWebview.show()
        console.log('[weizanLive] subWebview.show() 完成')
    } catch (e) {
        console.error('[weizanLive] createWeizanWebview 失败:', e)
        loadError.value = '直播间加载失败'
    }
    // #endif
}
/**
 * 给子 webview 注入微赞支付 UA 后缀。
 * - Android:取底层原生 WebView 后反射 getSettings().setUserAgentString
 * - iOS:反射 WKWebView setValue:forKey:@"customUserAgent"
 */
function injectUserAgent(webview, platform) {
    // #ifdef APP-PLUS
    if (platform === 'android') {
        try {
            // plus.webview.create 返回的是 JS 包装对象,不能直接反射,
            // 必须先取底层原生 WebView(android.webkit.WebView)再取 settings。
            const nativeWebView = getAndroidNativeWebView(webview)
            const settings = safeAndroidInvoke(nativeWebView, 'getSettings')
            if (settings) {
                const originalUA = safeAndroidInvoke(settings, 'getUserAgentString')
                const customUA = `${originalUA} ${WEIZAN_UA_SUFFIX}`
                safeAndroidInvoke(settings, 'setUserAgentString', customUA)
                console.log('[weizanLive] Android UA 注入完成:', customUA)
            } else {
                console.warn('[weizanLive] Android 未取到原生 WebView 或 getSettings 返回空,UA 未注入')
            }
        } catch (e) {
            console.error('[weizanLive] Android UA 注入失败:', e)
        }
    } else if (platform === 'ios') {
        try {
            // plus.webview 实例 → valueForKey:@"webView" 拿底层 WKWebView
            const wkWebView = safeIosInvoke(webview, 'valueForKey:', 'webView') || webview
            // 读取原 UA
            const originalUA = safeIosInvoke(wkWebView, 'valueForKey:', 'userAgent') || ''
            const customUA = `${originalUA} ${WEIZAN_UA_SUFFIX}`
            // WKWebView.customUserAgent 是可写属性,反射设置
            safeIosInvoke(wkWebView, 'setValue:forKey:', customUA, 'customUserAgent')
            console.log('[weizanLive] iOS UA 注入完成:', customUA)
        } catch (e) {
            console.error('[weizanLive] iOS UA 注入失败:', e)
        }
    }
    // #endif
}
/**
 * 取 plus.webview 实例底层的原生 android.webkit.WebView。
 * 优先用官方 nativeInstanceObject(),失败再退回 obtainWebview() 等兼容方式。
 */
function getAndroidNativeWebView(webview) {
    if (!webview) {
        return null
    }
    // 1. 官方 API:WebviewObject.nativeInstanceObject() 返回原生 WebView
    if (typeof webview.nativeInstanceObject === 'function') {
        try {
            const nativeObj = webview.nativeInstanceObject()
            if (nativeObj) {
                return nativeObj
            }
        } catch (e) {
            console.warn('[weizanLive] nativeInstanceObject 调用失败:', e && e.message)
        }
    }
    // 2. 兼容:部分运行时通过 obtainWebview 取原生 WebView
    if (typeof webview.obtainWebview === 'function') {
        try {
            const obtained = webview.obtainWebview()
            if (obtained) {
                return obtained
            }
        } catch (e) {}
    }
    return null
}
/**
 * 安全调用 plus.android.invoke,失败返回 null。
 */
function safeAndroidInvoke(obj, methodName, ...args) {
    try {
        return plus.android.invoke(obj, methodName, ...args)
    } catch (e) {
        console.warn('[weizanLive] android invoke fail:', methodName, e && e.message)
        return null
    }
}
/**
 * 安全调用 plus.ios.invoke,失败返回 null。
 */
function safeIosInvoke(obj, selector, ...args) {
    try {
        return plus.ios.invoke(obj, selector, ...args)
    } catch (e) {
        console.warn('[weizanLive] ios invoke fail:', selector, e && e.message)
        return null
    }
}
</script>
<style>
.weizan-live-container {
    width: 100%;
    height: 100vh;
    background-color: #FFFFFF;
}
</style>
traditionalApp/utils/weizan-pay-bridge.js
@@ -17,6 +17,7 @@
const PAY_BRIDGE_NAME = 'jumpToMiniProgramPay'
const PAY_BRIDGE_ANDROID_KEY = 'android' // H5 调 window.android.xxx
const LOG_TAG = '[weizanPay]'
/**
 * 给指定 webview 注册微赞支付 JSBridge。
@@ -52,9 +53,12 @@
    try {
        const platform = String(uni.getSystemInfoSync().platform || '').toLowerCase()
        if (platform === 'android') {
            // Android 移除注入的 JS 接口
            // Android 移除注入的 JS 接口(同样要作用在原生 WebView 上)
            try {
                plus.android.invoke(webviewInstance, 'removeJavascriptInterface', PAY_BRIDGE_ANDROID_KEY)
                const nativeWebView = getAndroidNativeWebView(webviewInstance)
                if (nativeWebView) {
                    plus.android.invoke(nativeWebView, 'removeJavascriptInterface', PAY_BRIDGE_ANDROID_KEY)
                }
            } catch (e) {}
        }
        // iOS:WKUserContentController 的 messageHandler 通常随 webview 销毁自动回收,
@@ -115,15 +119,33 @@
// ===== 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 {
        const JsInterface = plus.android.implements('io.dcloud.js.WeizanPayJsInterface', {
        // java.io.Serializable 一定存在、无强制方法,足以承载动态代理
        // H5 调 window.android.xxx 时由 Proxy 拦截转发到 JS 函数
        const JsInterface = plus.android.implements('java.io.Serializable', {
            [PAY_BRIDGE_NAME]: function (paramsJson) {
                // 注意:此日志运行在原生子线程
                console.log('[weizanPay][Android] JS 接口被触发, 收到原始字符串:', 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('[weizanPay][Android] 已切回 UI 线程, 开始解析参数')
                    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) {}
                    }
@@ -131,64 +153,189 @@
                })
            }
        })
        plus.android.invoke(webviewInstance, 'addJavascriptInterface', JsInterface, PAY_BRIDGE_ANDROID_KEY)
        console.log('[weizanPay][Android] JSBridge 已注册')
        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('[weizanPay][Android] 注册失败:', 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()。
 */
function getAndroidNativeWebView(webview) {
    if (!webview) {
        return null
    }
    // 1. 官方 API:WebviewObject.nativeInstanceObject() 返回原生 WebView
    if (typeof webview.nativeInstanceObject === 'function') {
        try {
            const nativeObj = webview.nativeInstanceObject()
            if (nativeObj) {
                return nativeObj
            }
        } catch (e) {
            console.warn(LOG_TAG, '[Android] nativeInstanceObject 调用失败:', e && e.message)
        }
    }
    // 2. 兼容:部分运行时通过 obtainWebview 取原生 WebView
    if (typeof webview.obtainWebview === 'function') {
        try {
            const obtained = webview.obtainWebview()
            if (obtained) {
                return obtained
            }
        } catch (e) {}
    }
    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('[weizanPay][iOS] messageHandler 被触发, message=', message)
                console.log(LOG_TAG, '===== [iOS] didReceiveScriptMessage 被调用 =====')
                if (!message) {
                    console.warn('[weizanPay][iOS] 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('[weizanPay][iOS] message.body=', body, '类型:', typeof body)
                    console.log(LOG_TAG, '[iOS] message.body=', safeStringify(body))
                    if (body !== undefined && body !== null) {
                        raw = body
                    }
                } catch (e) {
                    console.error('[weizanPay][iOS] 取 message.body 失败:', e)
                }
                } 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('[weizanPay][iOS] 未拿到 WKWebView 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('[weizanPay][iOS] 未拿到 userContentController')
            console.error(LOG_TAG, '[iOS] 未拿到 userContentController,注册中止')
            return
        }
        plus.ios.invoke(userContentController, 'addScriptMessageHandler:name:', MessageHandler, PAY_BRIDGE_NAME)
        console.log('[weizanPay][iOS] JSBridge 已注册')
        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('[weizanPay][iOS] 注册失败:', 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)。
 *