diff --git a/src/mobile/views/cashier/CashierEnvPage.vue b/src/mobile/views/cashier/CashierEnvPage.vue index 6e35b1e..283c397 100644 --- a/src/mobile/views/cashier/CashierEnvPage.vue +++ b/src/mobile/views/cashier/CashierEnvPage.vue @@ -19,6 +19,7 @@ import PayMethodIcon from '@/shared/components/pay/PayMethodIcon.vue' import QrCodeDisplay from '@/shared/components/pay/QrCodeDisplay.vue' import { useGatewayOrderPoll } from '@/shared/hooks/use-gateway-order-poll' import { closeWebview } from '@/shared/pay/close-webview' +import { prefetchDouyinJsapi } from '@/shared/pay/douyin' import { useGatewayAuth } from '@/shared/pay/use-gateway-auth' import { detectClientEnv, isValidH5ClientEnv } from '@/shared/utils/client-env' import { formatDateTime } from '@/shared/utils/datetime' @@ -305,8 +306,16 @@ async function loadPage() { } // 授权成功回跳后自动继续支付:保持 ready=false 让遮罩持续到支付发起 if (!isTerminal.value && !loadError.value && route.query.autoPay === '1' && selectId.value) { + // 抖音: OAuth 回跳后 URL(含 autoPay query) 已稳定, 预取后再自动支付 + if (clientEnvParam === 'douyin') { + prefetchDouyinJsapi({ orderNo }) + } await pay() } + else if (!isTerminal.value && !loadError.value && clientEnvParam === 'douyin') { + // 可交互页: 预取 SDK + jsapi-config(点支付复用; 若随后 OAuth 回跳会按新 URL 再预取) + prefetchDouyinJsapi({ orderNo }) + } // 支付未触发或已结束(未跳走):渲染业务内容 ready.value = true } @@ -336,7 +345,14 @@ const gatewayAuth = useGatewayAuth({ const returnPath = `/cashier/${encodeURIComponent(orderNo)}/${clientEnvParam}` + `?itemId=${encodeURIComponent(selectId.value)}&autoPay=1` - return generateGatewayAuthUrl({ orderNo, authType, returnPath }) + return generateGatewayAuthUrl({ + orderNo, + authType, + returnPath, + clientEnv: clientEnvParam, + itemId: selectId.value, + cashierType: 'h5', + }) }, // 抛错由 pay() 的 try/catch 接管, 与原实现行为一致 onError: (msg) => { @@ -372,7 +388,7 @@ async function ensureOpenIdOrRedirect(): Promise { * 处理 JSAPI 调起结果 */ async function handleJsapi(payload: string) { - const status = await invokeJsapiByEnv(clientEnvParam, payload) + const status = await invokeJsapiByEnv(clientEnvParam, payload, { orderNo }) if (status === 'ok') { // JSAPI 调起支付成功: 仅更新状态,成功卡片由 resultState 渲染、倒计时跳转由 watch 触发 order.value.status = 'paid' diff --git a/src/mobile/views/code-pay/shared/useCodePayPage.ts b/src/mobile/views/code-pay/shared/useCodePayPage.ts index 538139e..f2ca9d8 100644 --- a/src/mobile/views/code-pay/shared/useCodePayPage.ts +++ b/src/mobile/views/code-pay/shared/useCodePayPage.ts @@ -13,6 +13,7 @@ import { getCodePayInfo, } from '@/shared/api/code-pay' import { closeWebview } from '@/shared/pay/close-webview' +import { prefetchDouyinJsapi } from '@/shared/pay/douyin' import { invokeJsapiByEnv } from '@/shared/pay/jsapi' import { useGatewayAuth } from '@/shared/pay/use-gateway-auth' import { isAmountOverMax, yuanToFen } from '@/shared/utils/pay-amount' @@ -111,6 +112,7 @@ export function useCodePayPage(options: UseCodePayPageOptions) { // 仅 needOpenId===true 且尚未拿到 openId → 整段 OAuth(回跳即换 openId) // false / null / 缺省: 不跳转,可直接收款 if (info.value.needOpenId === true && !openId.value) { + // 禁止在跳转授权前预取(回跳 URL 可能变化, 签名会失效) const ok = await gatewayAuth.ensureOpenId(true, openId.value) if (!ok) { // 已跳转 OAuth 或失败(loadError 已由 onError 写入); 失败时显示错误卡 @@ -120,6 +122,10 @@ export function useCodePayPage(options: UseCodePayPageOptions) { return } } + // 抖音: OAuth 完成或无需授权后预取 SDK + jsapi-config(点支付复用) + if (clientEnv === 'douyin') { + prefetchDouyinJsapi({ code }) + } // 正常态:可渲染码牌收款 UI ready.value = true } @@ -201,7 +207,7 @@ export function useCodePayPage(options: UseCodePayPageOptions) { return case 'jsapi': try { - await invokeJsapiByEnv(clientEnv, action.payload) + await invokeJsapiByEnv(clientEnv, action.payload, { code }) markPaid(result) } catch (e: any) { diff --git a/src/shared/api/gateway.ts b/src/shared/api/gateway.ts index 8a4dfb6..e3277a3 100644 --- a/src/shared/api/gateway.ts +++ b/src/shared/api/gateway.ts @@ -116,6 +116,14 @@ export function generateGatewayAuthUrl(data: { orderNo: string authType: string returnPath: string + /** 客户端环境(微信/抖音通道解析用) */ + clientEnv?: string + /** 运行形态 h5/mini */ + runtime?: string + /** 收银台支付项 ID */ + itemId?: string + /** 收银台类型 h5/web/mini */ + cashierType?: string }): Promise { return http.request({ url: '/unipay/gateway/auth/generate-url', diff --git a/src/shared/pay/douyin.ts b/src/shared/pay/douyin.ts index 2e325ad..e34e77b 100644 --- a/src/shared/pay/douyin.ts +++ b/src/shared/pay/douyin.ts @@ -7,6 +7,9 @@ * 3. `sdk.config({...})` 通过签名验证 → `sdk.ready` 回调 * 4. `sdk.ttcjpay.dypay({sdk_info, success, fail})` 拉起抖音支付 * + * 页面级预取: OAuth 完成后可调用 prefetchDouyinJsapi, 并行加载 SDK + 验签包; + * 点支付时 invokeDouyinJsapi 复用缓存, 去掉调起路径上的额外 RTT。 + * * 参考文档: * - JSAPI 调起: https://pay.douyinpay.com/wiki/639fd48f17c2f3021d237f61/64413fddc6217f024ae23ae9.md * - JS 接入指南: https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/sdk/web-app/js/js-access @@ -34,15 +37,63 @@ export interface DouyinJsapiConfig { signature: string } -/** ttcjpay.dypay 响应(res.code 是外层 JSB 状态, res.data.code 是支付状态) */ +/** + * ttcjpay.dypay 响应 + * - 旧版 bridge.call: code 为数字型 JSB 状态, 支付结果在 data.code + * - 新版 sdk.ttcjpay.dypay(≥1.0.17): 支付结果在顶层 code(字符串 '0'/'1'/…), 双端结构一致 + */ interface DypayResult { - /** JSB 调用返回码: 1=成功, -1/-100=无权限, -2=客户端无此方法, -3=参数错误, 0=失败 */ - code?: number + /** + * 旧版: 数字 JSB 码(1=成功, -1/-100=无权限, -2=无方法, -3=参数错, 0=失败) + * 新版: 字符串支付码('0'=成功, '1'=取消, '2'=失败 …) + */ + code?: number | string data?: { - /** 支付状态: '0'=成功, '1'=取消, '2'=失败, '3'=业务传参错误, '4'=下单失败, '-1'=未知 */ + /** 旧版支付状态: '0'=成功, '1'=取消, '2'=失败, '3'=业务传参错误, '4'=下单失败, '-1'=未知 */ code?: string msg?: string } + /** 新版失败文案(部分回调可能带 message) */ + msg?: string + message?: string +} + +/** 抖音 JSAPI 验签上下文(与后端通道应用解析对齐) */ +export interface DouyinJsapiContext { + orderNo?: string + code?: string + channelMchNo?: string + capability?: string + channelAppId?: string +} + +/** 页面级预取缓存(url + ctx 一致时复用) */ +interface DouyinPrefetchCache { + key: string + configPromise: Promise +} + +let prefetchCache: DouyinPrefetchCache | null = null + +/** + * 当前页 URL(去掉 # 及后面), 作为 sdk.config 的 url 参数 + */ +function currentPageUrl(): string { + return location.href.split('#')[0] +} + +/** + * 规范化预取缓存 key(url + 上下文字段) + */ +function buildPrefetchKey(url: string, ctx?: DouyinJsapiContext): string { + return [ + url, + ctx?.orderNo || '', + ctx?.code || '', + ctx?.channelMchNo || '', + ctx?.capability || '', + ctx?.channelAppId || '', + ].join('|') } /** @@ -65,18 +116,85 @@ export async function ensureDouyinSdk(): Promise { * 调后端接口获取 sdk.config 验签包 * * @param url 当前页面 URL(不含 # 及后面部分) + * @param ctx 通道上下文(与 OAuth 同源; orderNo / code / channelMchNo 三选一) */ -export async function fetchDouyinJsapiConfig(url: string): Promise { +export async function fetchDouyinJsapiConfig( + url: string, + ctx?: DouyinJsapiContext, +): Promise { return http.request({ url: DOUYIN_JSAPI_CONFIG_URL, method: RequestEnum.GET, - params: { url }, + params: { + url, + orderNo: ctx?.orderNo, + code: ctx?.code, + channelMchNo: ctx?.channelMchNo, + capability: ctx?.capability, + channelAppId: ctx?.channelAppId, + }, }, { // 由调用方自行处理错误展示 isShowMessage: false, }) } +/** + * 页面级预取: 并行加载 JS-SDK + 拉取 sdk.config 验签包 + * + * 须在 OAuth 完成(或无需授权)、当前页 URL 已稳定后调用; + * 失败仅 console.warn, 不打断页面, 调起时走即时拉取兜底。 + * + * @param ctx 通道上下文(orderNo/code 等, 与 invokeDouyinJsapi 一致) + */ +export function prefetchDouyinJsapi(ctx?: DouyinJsapiContext): void { + if (typeof window === 'undefined') { + return + } + const url = currentPageUrl() + const key = buildPrefetchKey(url, ctx) + // 同 key 已在预取中或已完成, 不重复发起 + if (prefetchCache?.key === key) { + return + } + const configPromise = fetchDouyinJsapiConfig(url, ctx).catch((e) => { + // 预取失败丢弃缓存, 调起时重新拉 + if (prefetchCache?.key === key) { + prefetchCache = null + } + throw e + }) + prefetchCache = { key, configPromise } + // 并行加载 SDK(失败同样静默, 调起时再试) + void ensureDouyinSdk().catch((e) => { + console.warn('[douyin] prefetch sdk failed', e) + }) + void configPromise.catch((e) => { + console.warn('[douyin] prefetch jsapi-config failed', e) + }) +} + +/** + * 取预取缓存中的验签包; key 不匹配或失败则即时拉取 + */ +async function resolveJsapiConfig( + url: string, + ctx?: DouyinJsapiContext, +): Promise { + const key = buildPrefetchKey(url, ctx) + if (prefetchCache?.key === key) { + try { + return await prefetchCache.configPromise + } + catch { + // 预取失败, 下面即时拉取 + } + } + const configPromise = fetchDouyinJsapiConfig(url, ctx) + prefetchCache = { key, configPromise } + return configPromise +} + /** * 调用 sdk.config + sdk.ready, 完成 JSBridge 权限校验 * sdk.error 视为验签失败, reject @@ -162,44 +280,53 @@ export function callDypay(sdkInfoJson: string): Promise { /** * 抖音 JSAPI 调起主入口 * - * 完整流程: 注入 SDK → 拉取验签包 → sdk.config → ttcjpay.dypay → 解析结果 + * 完整流程: 注入 SDK → 拉取验签包(优先预取缓存) → sdk.config → ttcjpay.dypay → 解析结果 * * @param payBody 通道返回的 sdk_info JSON 字符串 + * @param ctx 通道上下文(orderNo/code 等, 与 OAuth 同源解析网站应用) * @throws Error('cancel') 用户取消 * @throws Error 其他失败 */ -export async function invokeDouyinJsapi(payBody: string): Promise { +export async function invokeDouyinJsapi( + payBody: string, + ctx?: DouyinJsapiContext, +): Promise { // 当前页面 URL(去掉 # 及后面), 作为 sdk.config 的 url 参数 - const url = location.href.split('#')[0] + const url = currentPageUrl() - // 1. 注入 JS-SDK + // 1. 注入 JS-SDK(预取可能已完成) await ensureDouyinSdk() - // 2. 拉取 sdk.config 验签包 - const cfg = await fetchDouyinJsapiConfig(url) + // 2. 验签包: 优先用页面级预取缓存 + const cfg = await resolveJsapiConfig(url, ctx) // 3. sdk.config 验签 await douyinSdkConfig(cfg) // 4. ttcjpay.dypay 调起支付 const res = await callDypay(payBody) - // 外层 code 校验(JSB_NO_PERMISSION/JSB_NO_HANDLER 等需明确报错) + // 仅数字型外层码才是旧版 JSB 错误(新版支付结果也是字符串 '0'/'1', 勿当 JSB 码) const outerCode = res.code - if (outerCode === -1 || outerCode === -100) { - throw new Error('Douyin JSB no permission (check JSBridge security domain config)') - } - if (outerCode === -2) { - throw new Error('Douyin JSB no handler (upgrade Douyin App)') - } - if (outerCode === -3) { - throw new Error('Douyin JSB params error (sdk_info invalid)') + if (typeof outerCode === 'number') { + if (outerCode === -1 || outerCode === -100) { + throw new Error('Douyin JSB no permission (check JSBridge security domain config)') + } + if (outerCode === -2) { + throw new Error('Douyin JSB no handler (upgrade Douyin App)') + } + if (outerCode === -3) { + throw new Error('Douyin JSB params error (sdk_info invalid)') + } } - // 支付状态码(在 res.data.code) - const payCode = res.data?.code + // 支付状态码: 优先旧版 data.code, 兼容新版顶层字符串 code + const payCode + = res.data?.code + ?? (typeof res.code === 'string' ? res.code : undefined) if (payCode === '0') { return } if (payCode === '1') { throw new Error('cancel') } - throw new Error(`Douyin pay failed: ${payCode || 'unknown'} ${res.data?.msg || ''}`) + const failMsg = res.data?.msg || res.msg || res.message || '' + throw new Error(`Douyin pay failed: ${payCode || 'unknown'} ${failMsg}`.trim()) } diff --git a/src/shared/pay/jsapi.ts b/src/shared/pay/jsapi.ts index 13ebd6a..a66ccc5 100644 --- a/src/shared/pay/jsapi.ts +++ b/src/shared/pay/jsapi.ts @@ -3,6 +3,7 @@ * payBody 一般为通道返回的 JSON 字符串 */ +import type { DouyinJsapiContext } from '@/shared/pay/douyin' import { invokeDouyinJsapi } from '@/shared/pay/douyin' export interface WechatJsapiPayload { @@ -139,8 +140,15 @@ export function invokeAlipayJsapi(payBody: string): Promise { /** * 按 clientEnv 调起 JSAPI + * @param clientEnv + * @param payBody + * @param douyinCtx 抖音验签上下文(orderNo/code), 与通道 OAuth 同源 */ -export async function invokeJsapiByEnv(clientEnv: string, payBody: string): Promise { +export async function invokeJsapiByEnv( + clientEnv: string, + payBody: string, + douyinCtx?: DouyinJsapiContext, +): Promise { if (clientEnv === 'wechat') { await invokeWechatJsapi(payBody) return @@ -152,7 +160,7 @@ export async function invokeJsapiByEnv(clientEnv: string, payBody: string): Prom if (clientEnv === 'douyin') { // 抖音: 动态加载 JS-SDK + sdk.config 验签 + ttcjpay.dypay // 抛出 Error('cancel') 表示用户取消, 其他 Error 表示失败 - await invokeDouyinJsapi(payBody) + await invokeDouyinJsapi(payBody, douyinCtx) return } // 云闪付等: 一期无统一桥, 抛出由上层轮询兜底 diff --git a/src/shared/pay/use-aggregate-pay.ts b/src/shared/pay/use-aggregate-pay.ts index 8675763..f985663 100644 --- a/src/shared/pay/use-aggregate-pay.ts +++ b/src/shared/pay/use-aggregate-pay.ts @@ -18,6 +18,7 @@ import { } from '@/shared/api/gateway' import { useGatewayOrderPoll } from '@/shared/hooks/use-gateway-order-poll' import { closeWebview } from '@/shared/pay/close-webview' +import { prefetchDouyinJsapi } from '@/shared/pay/douyin' import { invokeJsapiByEnv } from '@/shared/pay/jsapi' import { useGatewayAuth } from '@/shared/pay/use-gateway-auth' import { buildAggregateEnvPath } from '@/shared/utils/client-env' @@ -159,7 +160,13 @@ export function useAggregatePay(options: UseAggregatePayOptions) { const returnPath = buildAggregateEnvPath(orderNo, clientEnv) // clientEnv='union-pay' 已在 ensureOpenId 中提前 return, 不会进入此分支 const authType = clientEnv === 'douyin' ? 'douyin' : clientEnv - return generateGatewayAuthUrl({ orderNo, authType, returnPath }) + return generateGatewayAuthUrl({ + orderNo, + authType, + returnPath, + clientEnv, + runtime: 'h5', + }) }, onError: msg => onError?.(msg), failKey: 'aggregate.authFail', @@ -218,7 +225,7 @@ export function useAggregatePay(options: UseAggregatePayOptions) { break case 'jsapi': try { - await invokeJsapiByEnv(clientEnv, action.payload) + await invokeJsapiByEnv(clientEnv, action.payload, { orderNo }) // JSAPI 桥成功 → 立即标记已支付,模板切到整页成功卡片(与收银台/码牌语义一致) order.value.status = 'paid' clearCachedOrder(orderNo) @@ -322,9 +329,14 @@ export function useAggregatePay(options: UseAggregatePayOptions) { }) if (meta.value.needOpenId && !openId.value) { // 需要 OAuth:保持 ready=false,InitLoadingMask 持续显示直到跳转 + // 禁止在跳转授权前预取(回跳 URL 带 query, 签名会失效) await ensureOpenId() return } + // 抖音: OAuth 完成或无需授权后预取 SDK + jsapi-config(点支付复用) + if (clientEnv === 'douyin') { + prefetchDouyinJsapi({ orderNo }) + } // 仅配置显式 autoLaunch=true 时自动拉起 if (meta.value.autoLaunch === true) { // autoLaunch:保持 ready=false,由 paying 接管遮罩,避免订单卡闪现 diff --git a/src/shared/utils/pay-jsapi.ts b/src/shared/utils/pay-jsapi.ts index c5ce5ff..6667994 100644 --- a/src/shared/utils/pay-jsapi.ts +++ b/src/shared/utils/pay-jsapi.ts @@ -2,6 +2,7 @@ * 容器内 JSAPI 调起(微信公众号 / 支付宝生活号 / 抖音 H5) */ +import type { DouyinJsapiContext } from '@/shared/pay/douyin' import { invokeDouyinJsapi } from '@/shared/pay/douyin' /** 微信 JSAPI 调起参数(通道 payBody JSON) */ @@ -142,10 +143,12 @@ export async function invokeAlipayTradePay(payBody: string): Promise<'ok' | 'can /** * 按 clientEnv 调起 JSAPI + * @param douyinCtx 抖音验签上下文(orderNo/code), 与通道 OAuth 同源 */ export async function invokeJsapiByEnv( clientEnv: string, payBody: string, + douyinCtx?: DouyinJsapiContext, ): Promise<'ok' | 'cancel' | 'fail' | 'unsupported'> { if (clientEnv === 'wechat') { return invokeWechatJsapi(payBody) @@ -156,7 +159,7 @@ export async function invokeJsapiByEnv( if (clientEnv === 'douyin') { // 抖音: 调用 invokeDouyinJsapi(throw 'cancel' 表示取消, 其他 Error 表示失败) try { - await invokeDouyinJsapi(payBody) + await invokeDouyinJsapi(payBody, douyinCtx) return 'ok' } catch (e) {