fix: 对齐抖音支付失败及杀进程恢复跳转

This commit is contained in:
Codex
2026-09-10 09:20:50 +08:00
committed by charles
parent b5b6359d8e
commit f4c67cb20b
7 changed files with 142 additions and 1555 deletions
+15
View File
@@ -112,3 +112,18 @@ HBuilderX 5.24 App 资源编译及导出成功(有既有 CSS 注释警告)
本地 33 项支付测试通过,涵盖无弹窗、成功跳转、丢失回调、回调竞争、账号切换、页面栈满以及微信/支付宝成功页回归。尚未进行真机实付。 本地 33 项支付测试通过,涵盖无弹窗、成功跳转、丢失回调、回调竞争、账号切换、页面栈满以及微信/支付宝成功页回归。尚未进行真机实付。
HBuilderX 5.24 App 资源编译及导出成功,仅有既有 CSS Autoprefixer 注释警告。 HBuilderX 5.24 App 资源编译及导出成功,仅有既有 CSS Autoprefixer 注释警告。
## 失败与杀进程恢复页面对齐(2026-09-09 第四次补丁)
按用户要求直接复用微信、支付宝的支付失败页及其“确认”操作,不增加弹窗,不新增页面。
- SDK 取消/调用失败后,先查询支付结果;已确认支付成功仍进入成功页,其余本次未完成的支付进入现有失败页。
- 支付时杀死 App,重新打开后读取原订单并查单:已支付进入成功页;已关闭/未完成进入失败页,不自动唤起 SDK。
- 旧单关闭的点击直接结束在失败页,不在失败页背后同一次操作再创建新单;后续用户主动点击才继续新支付。
- 页面跳转只结束本次客户端支付交互,不代表服务端订单被置为失败。未知/网络异常仍保留原订单号并用已有 Toast 提示待确认,后续到账仍可查到成功;不自动关单、退款或丢弃待查记录。
- 同一进程中同一未完成订单只自动展示一次失败页,避免每次回前台又强制跳回。新的主动支付尝试重新计数。
- 成功/失败共用导航封装,页面栈满时尝试 redirectTo;跳转失败保留恢复记录。微信、支付宝仍使用原来的成功/失败页面。
39 项前端测试通过,包含杀进程后新运行上下文恢复、取消/原生错误、离线恢复、失败跳转重试、迟到成功及微信/支付宝失败页回归。
本次只改 JavaScript、测试和文档,不改后端、原生插件、SQL、接口或页面样式。尚未真机实付。
HBuilderX 5.24 App 资源编译与导出成功,只有既有 CSS Autoprefixer 注释警告。
-7
View File
@@ -113,8 +113,6 @@
</view> </view>
</view> </view>
<view @click="aaa">亲星星</view>
<view class="home_actived_warp"> <view class="home_actived_warp">
<view class="home_actived_item_warp" v-for="item in homeActivedList" :key="item.id"> <view class="home_actived_item_warp" v-for="item in homeActivedList" :key="item.id">
<view class="home_actived_item"> <view class="home_actived_item">
@@ -461,11 +459,6 @@ export default {
// #endif // #endif
}, },
methods: { methods: {
aaa() {
uni.navigateTo({
url: '/pages/other_package/douyin_pay_test/douyin_pay_test'
});
},
getScrollTop(e) { getScrollTop(e) {
let top = 0; let top = 0;
if (typeof e === "number") { if (typeof e === "number") {
File diff suppressed because it is too large Load Diff
+76 -3
View File
@@ -52,6 +52,7 @@ async function payContext(overrides = {}, app = true) {
return loadModule('utils/douyin-pay.js', context, app); return loadModule('utils/douyin-pay.js', context, app);
} }
const successPage = '/pages/order_package/order_submit_ok/order_submit_ok'; const successPage = '/pages/order_package/order_submit_ok/order_submit_ok';
const failurePage = '/pages/order_package/order_submit_error/order_submit_error';
test('SDK success is confirmed with server before showing success', async () => { test('SDK success is confirmed with server before showing success', async () => {
let queries = 0; let queries = 0;
const ctx = await payContext({ request: async () => { queries++; return { bizcode:100, data:{state:2} }; } }); const ctx = await payContext({ request: async () => { queries++; return { bizcode:100, data:{state:2} }; } });
@@ -65,7 +66,7 @@ test('SDK success plus network failure remains recoverable, never success', asyn
await ctx.appDypayFun(payment); await ctx.appDypayFun(payment);
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), 'order-1'); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), 'order-1');
assert.equal(ctx.uni.messages.includes('支付成功'), false); assert.equal(ctx.uni.messages.includes('支付成功'), false);
assert.deepEqual(ctx.uni.navigations, []); assert.deepEqual(ctx.uni.navigations, [failurePage]);
}); });
test('cancel still queries authoritative server state', async () => { test('cancel still queries authoritative server state', async () => {
const ctx = await payContext({ openDypay: (_, cb) => cb({ resultCode:'1' }) }); const ctx = await payContext({ openDypay: (_, cb) => cb({ resultCode:'1' }) });
@@ -195,7 +196,7 @@ test('cold start never opens the SDK or asks for payment and preserves pending s
ctx.uni.showModal = () => assert.fail('onShow must not ask for payment'); ctx.uni.showModal = () => assert.fail('onShow must not ask for payment');
await ctx.resumeDouyinPay(); await ctx.resumeDouyinPay();
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum);
assert.deepEqual(ctx.uni.navigations, []); assert.deepEqual(ctx.uni.navigations, [failurePage]);
}); });
test('unknown, processing, network error and missing backend remain locked', async () => { test('unknown, processing, network error and missing backend remain locked', async () => {
@@ -219,7 +220,8 @@ test('terminal server result clears saved order without payment and allows the n
? ({bizcode:100,data:{state,orderNum:payment.orderNum}}) ? ({bizcode:100,data:{state,orderNum:payment.orderNum}})
: ({bizcode:100,data:{state:2}})}); : ({bizcode:100,data:{state:2}})});
ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum);
assert.equal(await ctx.prepareDouyinPay('douyin'), state === 'closed'); assert.equal(await ctx.prepareDouyinPay('douyin'), false);
assert.deepEqual(ctx.uni.navigations, [state === 'paid' ? successPage : failurePage]);
assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false); assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false);
assert.equal(await ctx.prepareDouyinPay('douyin'), true); assert.equal(await ctx.prepareDouyinPay('douyin'), true);
} }
@@ -231,6 +233,9 @@ test('closed old transaction unlocks a fresh server order, never reopens old par
? {bizcode:100,data:{state:'closed',orderNum:'expired-order'}} : {bizcode:100,data:{state:2}}, ? {bizcode:100,data:{state:'closed',orderNum:'expired-order'}} : {bizcode:100,data:{state:2}},
openDypay:(options,cb)=>{opened=options.payInfo.prepayid; cb({resultCode:'0'});}}); openDypay:(options,cb)=>{opened=options.payInfo.prepayid; cb({resultCode:'0'});}});
ctx.uni.storage.set('tb-douyin:test-api:7','expired-order'); ctx.uni.storage.set('tb-douyin:test-api:7','expired-order');
assert.equal(await ctx.prepareDouyinPay('douyin'),false);
assert.deepEqual(ctx.uni.navigations,[failurePage]);
// A NEW user action may now create a fresh order; the closed attempt cannot do it implicitly.
assert.equal(await ctx.prepareDouyinPay('douyin'),true); assert.equal(await ctx.prepareDouyinPay('douyin'),true);
assert.equal(resets,1); assert.equal(resets,1);
const fresh={...payment,orderNum:'fresh-order',douyin:{...payment.douyin,prepayId:'fresh-prepay'}}; const fresh={...payment,orderNum:'fresh-order',douyin:{...payment.douyin,prepayId:'fresh-prepay'}};
@@ -405,4 +410,72 @@ test('native initialization failure before first payment does not leave a perman
const ctx = await payContext({initDypay:()=>false,openDypay:()=>assert.fail('Invalid SDK init')}); const ctx = await payContext({initDypay:()=>false,openDypay:()=>assert.fail('Invalid SDK init')});
assert.equal((await ctx.appDypayFun(payment)).state, 'error'); assert.equal((await ctx.appDypayFun(payment)).state, 'error');
assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false); assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false);
assert.deepEqual(ctx.uni.navigations, [failurePage]);
});
test('cancel and native failure use the same failure page without losing the unresolved transaction', async () => {
for (const code of ['1','2','100','103','-1']) {
const ctx=await payContext({openDypay:(_,cb)=>cb({resultCode:code}),queryUntilSettled:async()=>({state:'pending'})});
await ctx.appDypayFun(payment);
assert.deepEqual(ctx.uni.navigations,[failurePage]);
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum);
assert.equal(ctx.uni.messages.includes('支付成功'),false);
}
});
test('killing the App during SDK payment restores into the failure page rather than checkout or a modal', async () => {
const before=await payContext({openDypay:()=>{ /* Process dies before callback. */ }});
before.appDypayFun(payment);
assert.equal(before.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum);
const restarted=await payContext({openDypay:()=>assert.fail('Restart must never launch payment'),
queryUntilSettled:async()=>({state:'pending'})});
restarted.uni.storage.set('tb-douyin:test-api:7',before.uni.storage.get('tb-douyin:test-api:7'));
await restarted.resumeDouyinPay();
await restarted.resumeDouyinPay();
assert.deepEqual(restarted.uni.navigations,[failurePage]);
assert.equal(restarted.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum);
restarted.queryUntilSettled=async()=>({state:'paid'});
await restarted.resumeDouyinPay();
assert.deepEqual(restarted.uni.navigations,[failurePage,successPage]);
assert.equal(restarted.uni.storage.has('tb-douyin:test-api:7'),false);
});
test('restarting after a closed payment shows failure and clears only the confirmed terminal record', async () => {
const ctx=await payContext({queryUntilSettled:async()=>({state:'failed'})});
ctx.uni.storage.set('tb-douyin:test-api:7',payment.orderNum);
await ctx.resumeDouyinPay();
assert.deepEqual(ctx.uni.navigations,[failurePage]);
assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'),false);
});
test('offline restart exits checkout but retains the order for later authoritative reconciliation', async () => {
const ctx=await payContext({queryUntilSettled:async()=>{throw new Error('offline');}});
ctx.uni.storage.set('tb-douyin:test-api:7',payment.orderNum);
await ctx.resumeDouyinPay();
await ctx.resumeDouyinPay();
assert.deepEqual(ctx.uni.navigations,[failurePage]);
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum);
assert.match(ctx.uni.messages.at(-1),/待确认/);
});
test('failure navigation fallback and retry never forget a terminal order before presenting the result', async () => {
const ctx=await payContext({queryUntilSettled:async()=>({state:'failed'})});
ctx.uni.storage.set('tb-douyin:test-api:7',payment.orderNum);
ctx.uni.navigateTo=options=>options.fail();
ctx.uni.redirectTo=options=>options.fail();
await ctx.resumeDouyinPay();
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum);
ctx.uni.redirectTo=options=>{ctx.uni.navigations.push(options.url);options.success();};
await ctx.resumeDouyinPay();
assert.deepEqual(ctx.uni.navigations,[failurePage]);
assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'),false);
});
test('WeChat and Alipay failures retain the same shared failure page', async () => {
const ctx=await payContext({console:{log(){},error(){}}});
loadModule('utils/payUtils.js',ctx);
ctx.uni.requestPayment=options=>options.fail({});
ctx.appWxpayFun({type:'app'},1,'wx');
ctx.zfbPayFun({type:'app'},2,'ali');
assert.deepEqual(ctx.uni.navigations,[failurePage,failurePage]);
}); });
+39 -10
View File
@@ -5,12 +5,13 @@ import request from './request.js';
import { BASE_URL } from './config.js'; import { BASE_URL } from './config.js';
import { TOKEN_NAME, USER_DATA } from './auth.js'; import { TOKEN_NAME, USER_DATA } from './auth.js';
import { toSdkPayInfo, queryUntilSettled } from './douyin-pay-adapter.mjs'; import { toSdkPayInfo, queryUntilSettled } from './douyin-pay-adapter.mjs';
import { jumpToPaymentSuccess } from './payment-navigation.js'; import { jumpToPaymentSuccess, jumpToPaymentFailure } from './payment-navigation.js';
let active = false; let active = false;
let resuming = false; let resuming = false;
let inFlight = null; let inFlight = null;
let checkingForeground = false; let checkingForeground = false;
const failureShown = new Map();
function accountKey() { function accountKey() {
if (!uni.getStorageSync(TOKEN_NAME)) return ''; if (!uni.getStorageSync(TOKEN_NAME)) return '';
const user = uni.getStorageSync(USER_DATA) || {}; const user = uni.getStorageSync(USER_DATA) || {};
@@ -57,12 +58,25 @@ async function showState(state, key, orderNum) {
uni.showToast({ title: '支付成功,请在订单中查看', icon: 'none' }); uni.showToast({ title: '支付成功,请在订单中查看', icon: 'none' });
return; 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); if (state.state !== 'pending' && uni.getStorageSync(key) === orderNum) uni.removeStorageSync(key);
uni.$emit('douyin-payment-result', { orderNum, state: state.state }); uni.$emit('douyin-payment-result', { orderNum, state: state.state });
if (state.state === 'paid' || state.state === 'closed') return; if (state.state !== 'pending') return;
uni.showToast({ title: state.state === 'failed' ? '支付未成功,请查看订单' uni.showToast({ title: state.message || '支付结果待确认,请稍后查看订单', icon: 'none', duration: 2500 });
: state.message || '支付结果待确认,请稍后查看订单', icon: 'none', duration: 2500 });
} }
// Called only by a user payment action, never by onShow. Renew the SAME transaction on the server. // Called only by a user payment action, never by onShow. Renew the SAME transaction on the server.
@@ -108,6 +122,7 @@ async function executePayment(payment, key) {
throw new Error('支付初始化失败,请稍后重试'); throw new Error('支付初始化失败,请稍后重试');
} }
// Persist before entering native code so a killed process remains recoverable. // Persist before entering native code so a killed process remains recoverable.
failureShown.delete(key);
uni.setStorageSync(key, payment.orderNum); uni.setStorageSync(key, payment.orderNum);
const session = { key, orderNum: payment.orderNum, settled: false, finish: null }; const session = { key, orderNum: payment.orderNum, settled: false, finish: null };
inFlight = session; inFlight = session;
@@ -136,6 +151,7 @@ async function executePayment(payment, key) {
// Runs on the payment click BEFORE creating an order. Resume a live original transaction directly. // Runs on the payment click BEFORE creating an order. Resume a live original transaction directly.
export async function prepareDouyinPay(payway) { export async function prepareDouyinPay(payway) {
if (payway !== 'douyin') return true; if (payway !== 'douyin') return true;
let paymentKey = '';
try { try {
// #ifndef APP-PLUS // #ifndef APP-PLUS
throw new Error('抖音支付仅支持 Android/iOS App'); throw new Error('抖音支付仅支持 Android/iOS App');
@@ -143,15 +159,17 @@ export async function prepareDouyinPay(payway) {
// #ifdef APP-PLUS // #ifdef APP-PLUS
if (active || resuming) throw new Error('正在处理支付,请勿重复点击'); if (active || resuming) throw new Error('正在处理支付,请勿重复点击');
const key = accountKey(); const key = accountKey();
paymentKey = key;
if (!key) throw new Error('请先登录后再支付'); if (!key) throw new Error('请先登录后再支付');
const previous = uni.getStorageSync(key); const previous = uni.getStorageSync(key);
if (previous) { if (previous) {
failureShown.delete(key);
resuming = true; resuming = true;
try { try {
const result = await continuePendingPayment(key, previous); await continuePendingPayment(key, previous);
if (result.state !== 'closed' || key !== accountKey() || uni.getStorageSync(key)) return false; // A completed/closed attempt has already entered its result page. Do not create
if (!canOpenDypay()) throw new Error('请先安装或升级抖音客户端'); // another order behind that page during the same click.
return true; return false;
} }
finally { resuming = false; } finally { resuming = false; }
} }
@@ -159,6 +177,11 @@ export async function prepareDouyinPay(payway) {
return true; return true;
// #endif // #endif
} catch (error) { } catch (error) {
const pending = paymentKey && paymentKey === accountKey() && uni.getStorageSync(paymentKey);
if (pending) {
await showState({ state: 'pending', message: error.message }, paymentKey, pending);
return false;
}
uni.showToast({ title: error.message || '暂时无法支付,请稍后重试', icon: 'none' }); uni.showToast({ title: error.message || '暂时无法支付,请稍后重试', icon: 'none' });
return false; return false;
} }
@@ -183,7 +206,9 @@ export async function appDypayFun(payment) {
} }
return await executePayment(payment, key); return await executePayment(payment, key);
} catch (error) { } catch (error) {
if (key === accountKey()) uni.showToast({ title: error.message || '支付结果待确认,请查看订单', icon: 'none' }); if (key && key === accountKey()) {
await showState({ state: 'pending', message: error.message || '支付结果待确认,请查看订单' }, key, payment?.orderNum || '');
}
return { state: 'error', message: error.message || '支付结果待确认,请查看订单' }; return { state: 'error', message: error.message || '支付结果待确认,请查看订单' };
} finally { active = false; } } finally { active = false; }
// #endif // #endif
@@ -217,7 +242,11 @@ export async function resumeDouyinPay() {
if (key === accountKey() && state.state !== 'pending') resetDypay(); if (key === accountKey() && state.state !== 'pending') resetDypay();
await showState(state, key, pending); await showState(state, key, pending);
} }
catch (_) { /* Keep the order for a later onShow. Never infer a payment failure. */ } 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; } finally { resuming = false; }
// #endif // #endif
} }
+4 -6
View File
@@ -1,5 +1,5 @@
import { appDypayFun } from './douyin-pay.js'; import { appDypayFun } from './douyin-pay.js';
import { jumpToPaymentSuccess } from './payment-navigation.js'; import { jumpToPaymentSuccess, jumpToPaymentFailure } from './payment-navigation.js';
import { feedback } from "@/api/payment.js"; import { feedback } from "@/api/payment.js";
/** /**
@@ -128,11 +128,9 @@ export function jumpWayOk() {
return jumpToPaymentSuccess(); return jumpToPaymentSuccess();
} }
// 跳转到支付失败页面 // 跳转到支付失败页面
export function jumpWayError() { export function jumpWayError() {
uni.navigateTo({ return jumpToPaymentFailure();
url: "/pages/order_package/order_submit_error/order_submit_error", }
});
}
/** /**
* 支付结果回调 * 支付结果回调
* @param {Object} orderId 支付订单ID * @param {Object} orderId 支付订单ID
+8 -1
View File
@@ -1,6 +1,13 @@
// Shared result pages for WeChat, Alipay and Douyin. Keep the existing page design. // Shared result pages for WeChat, Alipay and Douyin. Keep the existing page design.
export function jumpToPaymentSuccess() { export function jumpToPaymentSuccess() {
const url = '/pages/order_package/order_submit_ok/order_submit_ok'; return jumpToPaymentResult('/pages/order_package/order_submit_ok/order_submit_ok');
}
export function jumpToPaymentFailure() {
return jumpToPaymentResult('/pages/order_package/order_submit_error/order_submit_error');
}
function jumpToPaymentResult(url) {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []; const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [];
if (pages[pages.length - 1]?.route === url.slice(1)) return Promise.resolve(true); if (pages[pages.length - 1]?.route === url.slice(1)) return Promise.resolve(true);
return new Promise(resolve => { return new Promise(resolve => {