feat(douyin): 页面级预取 JSAPI 验签包,缩短调起等待

OAuth 完成后预加载 SDK 与 jsapi-config,点支付复用缓存;同步补齐调起上下文与 dypay 新旧回调兼容。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bootx
2026-07-22 17:14:36 +08:00
parent 0414375886
commit 8b324a770a
7 changed files with 212 additions and 32 deletions

View File

@@ -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<string | null> {
* 处理 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'

View File

@@ -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) {

View File

@@ -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<GatewayAuthUrlResult> {
return http.request<GatewayAuthUrlResult>({
url: '/unipay/gateway/auth/generate-url',

View File

@@ -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<DouyinJsapiConfig>
}
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<void> {
* 调后端接口获取 sdk.config 验签包
*
* @param url 当前页面 URL(不含 # 及后面部分)
* @param ctx 通道上下文(与 OAuth 同源; orderNo / code / channelMchNo 三选一)
*/
export async function fetchDouyinJsapiConfig(url: string): Promise<DouyinJsapiConfig> {
export async function fetchDouyinJsapiConfig(
url: string,
ctx?: DouyinJsapiContext,
): Promise<DouyinJsapiConfig> {
return http.request<DouyinJsapiConfig>({
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<DouyinJsapiConfig> {
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<DypayResult> {
/**
* 抖音 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<void> {
export async function invokeDouyinJsapi(
payBody: string,
ctx?: DouyinJsapiContext,
): Promise<void> {
// 当前页面 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())
}

View File

@@ -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<void> {
/**
* 按 clientEnv 调起 JSAPI
* @param clientEnv
* @param payBody
* @param douyinCtx 抖音验签上下文(orderNo/code), 与通道 OAuth 同源
*/
export async function invokeJsapiByEnv(clientEnv: string, payBody: string): Promise<void> {
export async function invokeJsapiByEnv(
clientEnv: string,
payBody: string,
douyinCtx?: DouyinJsapiContext,
): Promise<void> {
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
}
// 云闪付等: 一期无统一桥, 抛出由上层轮询兜底

View File

@@ -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=falseInitLoadingMask 持续显示直到跳转
// 禁止在跳转授权前预取(回跳 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 接管遮罩,避免订单卡闪现

View File

@@ -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) {