// #ifdef APP-PLUS import { initDypay, canOpenDypay, openDypay, resetDypay } from '@/uni_modules/tb-douyin-pay'; // #endif import request from './request.js'; import { BASE_URL } from './config.js'; import { TOKEN_NAME, USER_DATA } from './auth.js'; import { toSdkPayInfo, queryUntilSettled } from './douyin-pay-adapter.mjs'; import { jumpToPaymentSuccess, jumpToPaymentFailure } from './payment-navigation.js'; let active = false; let resuming = false; let inFlight = null; let checkingForeground = false; const failureShown = new Map(); function accountKey() { if (!uni.getStorageSync(TOKEN_NAME)) return ''; const user = uni.getStorageSync(USER_DATA) || {}; const id = user.userId || user.id; return id ? `tb-douyin:${BASE_URL}:${id}` : ''; } async function query(orderNum) { return queryUntilSettled(async options => { const response = await request({ ...options, isShowLoading: false, timeout: 30000 }); if (response?.bizcode !== 100) throw new Error('查询支付结果失败'); return response.data; }, orderNum); } export function douyinPayTypes(types) { // #ifdef APP-PLUS return types.split(',').includes('6') ? types : `${types},6`; // #endif // #ifndef APP-PLUS return types; // #endif } export function filterDouyinPayWays(ways) { // #ifdef APP-PLUS return ways || []; // #endif // #ifndef APP-PLUS return (ways || []).filter(item => item.type !== 'douyin'); // #endif } async function showState(state, key, orderNum) { if (key !== accountKey()) return; if (state.state === 'paid') { const navigated = await jumpToPaymentSuccess(); if (key !== accountKey()) return; if (!navigated) { // Retain the record so the next onShow/click can retry navigation without paying again. uni.showToast({ title: '支付成功,请在订单中查看', icon: 'none' }); return; } failureShown.delete(key); } else { // Same failure page as WeChat/Alipay. Ending the UI attempt is not a channel close. // Keep unresolved orders for reconciliation, but do not push the page on every onShow. const alreadyShown = failureShown.get(key) === orderNum; if (!alreadyShown) { const navigated = await jumpToPaymentFailure(); if (key !== accountKey()) return; if (!navigated) { uni.showToast({ title: '支付未完成,请查看订单', icon: 'none' }); return; } failureShown.set(key, orderNum); } else if (state.state === 'pending') return; } if (state.state !== 'pending' && uni.getStorageSync(key) === orderNum) uni.removeStorageSync(key); uni.$emit('douyin-payment-result', { orderNum, state: state.state }); if (state.state !== 'pending') return; uni.showToast({ title: state.message || '支付结果待确认,请稍后查看订单', icon: 'none', duration: 2500 }); } // Only called when the CURRENT server response names the cached transaction. // A payment click for another product must never authorize this old transaction. async function continuePendingPayment(key, orderNum) { const response = await request({ url: '/payment/douyin/resume', method: 'POST', data: { orderNum }, isShowLoading: false, timeout: 60000 }); if (key !== accountKey()) return { state: 'pending' }; if (response?.bizcode !== 100) throw new Error('暂时无法续付,请查看订单后重试'); const result = response.data; if (!result || result.orderNum !== orderNum) throw new Error('续付订单不匹配,请查看订单'); if (result.state === 'paid' || result.state === 'closed') { // The existing query endpoint also reconciles business orders when a webhook was missed. const state = result.state === 'paid' ? await query(orderNum) : { state: 'closed' }; if (key === accountKey() && state.state !== 'pending') resetDypay(); await showState(state, key, orderNum); return state; } if (result.state !== 'unpaid') { const messages = { QUERY_FAILED: '抖音查单失败,暂不能重新支付,请稍后重试', CLOSE_UNCONFIRMED: '旧支付关单尚未确认,请稍后重试', RECONCILIATION_REQUIRED: '订单状态需核对,请联系客服,勿重复付款', CHANNEL_PENDING: '抖音支付处理中,请稍后查看订单' }; const state = { state: 'pending', message: messages[result.reason] }; await showState(state, key, orderNum); return state; } if (result.payment?.orderNum !== orderNum) throw new Error('续付订单不匹配,请查看订单'); // The user's payment click already authorizes continuation. No additional modal. // Validate the original server parameters; never store the SDK signature locally. toSdkPayInfo(result.payment); resetDypay(); return executePayment(result.payment, key); } async function executePayment(payment, key) { // #ifdef APP-PLUS if (key !== accountKey()) return { state: 'pending' }; const payInfo = toSdkPayInfo(payment); if (!canOpenDypay()) throw new Error('请先安装或升级抖音客户端'); // iOS must return to this installed host's registered Scheme. The previous // default Universal Link serves HTML instead of an Apple association file; // Dypay prioritizes that link over the working Scheme. Do not register it. const isIOS = uni.getSystemInfoSync().platform === 'ios'; const callbackScheme = isIOS ? 'trustbridgeapp' : (payment.douyin?.callbackScheme || 'trustbridgeapp'); if (!initDypay({ appId: payInfo.appid, callbackScheme })) { throw new Error('支付初始化失败,请稍后重试'); } // Persist before entering native code so a killed process remains recoverable. failureShown.delete(key); uni.setStorageSync(key, payment.orderNum); const session = { key, orderNum: payment.orderNum, settled: false, finish: null }; inFlight = session; try { const nativeResult = await new Promise(resolve => { session.finish = result => { if (session.settled) return; session.settled = true; resolve(result); }; openDypay({ payInfo, showLoading: true }, session.finish); }); const state = nativeResult?.serverState || await query(payment.orderNum); if (state.state === 'pending' && nativeResult?.resultCode && !['0', '3'].includes(String(nativeResult.resultCode))) { // A native error is diagnostic only: never clear a potentially live payment on its authority. const descriptions = { '1': '支付已取消', '2': '支付 SDK 调用失败', '100': '请安装或升级抖音客户端', '103': '原生支付仍在处理中', '-1': '支付参数校验失败' }; state.message = `${descriptions[String(nativeResult.resultCode)] || '支付未完成'},请查询订单后重试`; } await showState(state, key, payment.orderNum); return state; } finally { if (inFlight === session) inFlight = null; } // #endif } // Like WeChat/Alipay, let the current checkout/recharge/order-payment request // choose the transaction. The account-wide recovery record is NOT that choice. export async function prepareDouyinPay(payway) { if (payway !== 'douyin') return true; try { // #ifndef APP-PLUS throw new Error('抖音支付仅支持 Android/iOS App'); // #endif // #ifdef APP-PLUS if (active || resuming) throw new Error('正在处理支付,请勿重复点击'); const key = accountKey(); if (!key) throw new Error('请先登录后再支付'); if (!canOpenDypay()) throw new Error('请先安装或升级抖音客户端'); return true; // #endif } catch (error) { uni.showToast({ title: error.message || '暂时无法支付,请稍后重试', icon: 'none' }); return false; } } // Takes the existing settlement/recharge response; does NOT create a second order. export async function appDypayFun(payment) { // #ifndef APP-PLUS uni.showToast({ title: '抖音支付仅支持 Android/iOS App', icon: 'none' }); return; // #endif // #ifdef APP-PLUS if (active || resuming) return; active = true; const key = accountKey(); try { if (!key || !payment.orderNum) throw new Error('登录身份或支付订单编号缺失'); const previous = uni.getStorageSync(key); if (previous === payment.orderNum) { // An explicit retry of this same server-selected order still needs reconciliation. failureShown.delete(key); return await continuePendingPayment(key, payment.orderNum); } // Validate the CURRENT response before releasing any stale native callback. // This only resets the client bridge, not the previous server order. Its // settlement remains handled by backend callbacks/query jobs/order details. toSdkPayInfo(payment); if (previous) resetDypay(); return await executePayment(payment, key); } catch (error) { if (key && key === accountKey()) { await showState({ state: 'pending', message: error.message || '支付结果待确认,请查看订单' }, key, payment?.orderNum || ''); } return { state: 'error', message: error.message || '支付结果待确认,请查看订单' }; } finally { active = false; } // #endif } export async function resumeDouyinPay() { // #ifdef APP-PLUS // The SDK callback may be lost when returning from Douyin. Reconcile onShow even // while native payment is waiting; only a terminal server result can finish it. const session = inFlight; if (session) { if (checkingForeground || session.settled || session.key !== accountKey()) return; checkingForeground = true; try { const state = await query(session.orderNum); if (session === inFlight && !session.settled && session.key === accountKey() && state.state !== 'pending') { resetDypay(); session.finish({ serverState: state }); } } catch (_) { /* A failed foreground query must not unlock a live transaction. */ } finally { checkingForeground = false; } return; } if (active || resuming) return; const key = accountKey(); const pending = key && uni.getStorageSync(key); if (!pending) return; resuming = true; try { const state = await query(pending); if (key === accountKey() && state.state !== 'pending') resetDypay(); await showState(state, key, pending); } catch (_) { // The interrupted attempt uses the existing failure page, without marking the // server transaction failed or forgetting an order whose result is still unknown. await showState({ state: 'pending', message: '支付结果待确认,请稍后查看订单' }, key, pending); } finally { resuming = false; } // #endif }