2026-09-09 12:00:13 +08:00
|
|
|
// #ifdef APP-PLUS
|
2026-09-09 16:29:30 +08:00
|
|
|
import { initDypay, canOpenDypay, openDypay, resetDypay } from '@/uni_modules/tb-douyin-pay';
|
2026-09-09 12:00:13 +08:00
|
|
|
// #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';
|
2026-09-09 17:46:42 +08:00
|
|
|
import { jumpToPaymentSuccess, jumpToPaymentFailure } from './payment-navigation.js';
|
2026-09-09 12:00:13 +08:00
|
|
|
|
|
|
|
|
let active = false;
|
|
|
|
|
let resuming = false;
|
2026-09-09 17:30:00 +08:00
|
|
|
let inFlight = null;
|
|
|
|
|
let checkingForeground = false;
|
2026-09-09 17:46:42 +08:00
|
|
|
const failureShown = new Map();
|
2026-09-09 12:00:13 +08:00
|
|
|
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 => {
|
2026-09-09 16:29:30 +08:00
|
|
|
const response = await request({ ...options, isShowLoading: false, timeout: 30000 });
|
2026-09-09 12:00:13 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-23 10:29:41 +08:00
|
|
|
let pendingSuccessUrl = '';
|
|
|
|
|
|
2026-09-09 17:30:00 +08:00
|
|
|
async function showState(state, key, orderNum) {
|
2026-09-09 12:00:13 +08:00
|
|
|
if (key !== accountKey()) return;
|
2026-09-09 17:30:00 +08:00
|
|
|
if (state.state === 'paid') {
|
2026-09-23 10:29:41 +08:00
|
|
|
const navigated = await jumpToPaymentSuccess(pendingSuccessUrl);
|
2026-09-09 17:30:00 +08:00
|
|
|
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;
|
|
|
|
|
}
|
2026-09-09 17:46:42 +08:00
|
|
|
failureShown.delete(key);
|
2026-09-23 10:29:41 +08:00
|
|
|
pendingSuccessUrl = '';
|
2026-09-09 17:46:42 +08:00
|
|
|
} 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;
|
2026-09-09 17:30:00 +08:00
|
|
|
}
|
2026-09-09 12:00:13 +08:00
|
|
|
if (state.state !== 'pending' && uni.getStorageSync(key) === orderNum) uni.removeStorageSync(key);
|
|
|
|
|
uni.$emit('douyin-payment-result', { orderNum, state: state.state });
|
2026-09-09 17:46:42 +08:00
|
|
|
if (state.state !== 'pending') return;
|
|
|
|
|
uni.showToast({ title: state.message || '支付结果待确认,请稍后查看订单', icon: 'none', duration: 2500 });
|
2026-09-09 12:00:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-10 11:12:30 +08:00
|
|
|
// Only called when the CURRENT server response names the cached transaction.
|
|
|
|
|
// A payment click for another product must never authorize this old transaction.
|
2026-09-09 14:39:07 +08:00
|
|
|
async function continuePendingPayment(key, orderNum) {
|
|
|
|
|
const response = await request({ url: '/payment/douyin/resume', method: 'POST',
|
2026-09-09 16:29:30 +08:00
|
|
|
data: { orderNum }, isShowLoading: false, timeout: 60000 });
|
2026-09-09 14:39:07 +08:00
|
|
|
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.
|
2026-09-09 16:29:30 +08:00
|
|
|
const state = result.state === 'paid' ? await query(orderNum) : { state: 'closed' };
|
|
|
|
|
if (key === accountKey() && state.state !== 'pending') resetDypay();
|
2026-09-09 17:30:00 +08:00
|
|
|
await showState(state, key, orderNum);
|
2026-09-09 14:39:07 +08:00
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
if (result.state !== 'unpaid') {
|
2026-09-09 16:29:30 +08:00
|
|
|
const messages = {
|
|
|
|
|
QUERY_FAILED: '抖音查单失败,暂不能重新支付,请稍后重试',
|
|
|
|
|
CLOSE_UNCONFIRMED: '旧支付关单尚未确认,请稍后重试',
|
|
|
|
|
RECONCILIATION_REQUIRED: '订单状态需核对,请联系客服,勿重复付款',
|
|
|
|
|
CHANNEL_PENDING: '抖音支付处理中,请稍后查看订单'
|
|
|
|
|
};
|
|
|
|
|
const state = { state: 'pending', message: messages[result.reason] };
|
2026-09-09 17:30:00 +08:00
|
|
|
await showState(state, key, orderNum);
|
2026-09-09 14:39:07 +08:00
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
if (result.payment?.orderNum !== orderNum) throw new Error('续付订单不匹配,请查看订单');
|
2026-09-09 17:30:00 +08:00
|
|
|
// The user's payment click already authorizes continuation. No additional modal.
|
|
|
|
|
// Validate the original server parameters; never store the SDK signature locally.
|
2026-09-09 14:39:07 +08:00
|
|
|
toSdkPayInfo(result.payment);
|
2026-09-09 16:29:30 +08:00
|
|
|
resetDypay();
|
2026-09-09 14:39:07 +08:00
|
|
|
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('请先安装或升级抖音客户端');
|
2026-09-11 10:52:28 +08:00
|
|
|
// 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 })) {
|
2026-09-09 14:39:07 +08:00
|
|
|
throw new Error('支付初始化失败,请稍后重试');
|
|
|
|
|
}
|
|
|
|
|
// Persist before entering native code so a killed process remains recoverable.
|
2026-09-09 17:46:42 +08:00
|
|
|
failureShown.delete(key);
|
2026-09-09 14:39:07 +08:00
|
|
|
uni.setStorageSync(key, payment.orderNum);
|
2026-09-09 17:30:00 +08:00
|
|
|
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; }
|
2026-09-09 14:39:07 +08:00
|
|
|
// #endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 11:12:30 +08:00
|
|
|
// Like WeChat/Alipay, let the current checkout/recharge/order-payment request
|
|
|
|
|
// choose the transaction. The account-wide recovery record is NOT that choice.
|
2026-09-09 12:00:13 +08:00
|
|
|
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
|
2026-09-09 14:39:07 +08:00
|
|
|
if (active || resuming) return;
|
2026-09-09 12:00:13 +08:00
|
|
|
active = true;
|
2026-09-23 10:29:41 +08:00
|
|
|
pendingSuccessUrl = payment?.customSuccessUrl || '';
|
2026-09-09 12:00:13 +08:00
|
|
|
const key = accountKey();
|
|
|
|
|
try {
|
|
|
|
|
if (!key || !payment.orderNum) throw new Error('登录身份或支付订单编号缺失');
|
|
|
|
|
const previous = uni.getStorageSync(key);
|
2026-09-10 11:12:30 +08:00
|
|
|
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);
|
2026-09-09 12:00:13 +08:00
|
|
|
}
|
2026-09-10 11:12:30 +08:00
|
|
|
// 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();
|
2026-09-09 14:39:07 +08:00
|
|
|
return await executePayment(payment, key);
|
2026-09-09 12:00:13 +08:00
|
|
|
} catch (error) {
|
2026-09-09 17:46:42 +08:00
|
|
|
if (key && key === accountKey()) {
|
|
|
|
|
await showState({ state: 'pending', message: error.message || '支付结果待确认,请查看订单' }, key, payment?.orderNum || '');
|
|
|
|
|
}
|
2026-09-09 12:00:13 +08:00
|
|
|
return { state: 'error', message: error.message || '支付结果待确认,请查看订单' };
|
|
|
|
|
} finally { active = false; }
|
|
|
|
|
// #endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function resumeDouyinPay() {
|
|
|
|
|
// #ifdef APP-PLUS
|
2026-09-09 17:30:00 +08:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-09-09 12:00:13 +08:00
|
|
|
if (active || resuming) return;
|
|
|
|
|
const key = accountKey();
|
|
|
|
|
const pending = key && uni.getStorageSync(key);
|
|
|
|
|
if (!pending) return;
|
|
|
|
|
resuming = true;
|
2026-09-09 16:29:30 +08:00
|
|
|
try {
|
|
|
|
|
const state = await query(pending);
|
|
|
|
|
if (key === accountKey() && state.state !== 'pending') resetDypay();
|
2026-09-09 17:30:00 +08:00
|
|
|
await showState(state, key, pending);
|
2026-09-09 16:29:30 +08:00
|
|
|
}
|
2026-09-09 17:46:42 +08:00
|
|
|
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);
|
|
|
|
|
}
|
2026-09-09 12:00:13 +08:00
|
|
|
finally { resuming = false; }
|
|
|
|
|
// #endif
|
|
|
|
|
}
|