From f4c67cb20b3dcabe9c88c5c23ba52620f91ea47b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 17:46:42 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AF=B9=E9=BD=90=E6=8A=96=E9=9F=B3?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E5=A4=B1=E8=B4=A5=E5=8F=8A=E6=9D=80=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E6=81=A2=E5=A4=8D=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/douyin-pay-integration.md | 15 + pages/home/home.vue | 7 - .../order_submit/order_submit2.vue | 1528 ----------------- tests/douyin-integration.test.cjs | 79 +- utils/douyin-pay.js | 49 +- utils/payUtils.js | 10 +- utils/payment-navigation.js | 9 +- 7 files changed, 142 insertions(+), 1555 deletions(-) delete mode 100644 pages/order_package/order_submit/order_submit2.vue diff --git a/docs/douyin-pay-integration.md b/docs/douyin-pay-integration.md index 0a41e84..4f2fda1 100644 --- a/docs/douyin-pay-integration.md +++ b/docs/douyin-pay-integration.md @@ -112,3 +112,18 @@ HBuilderX 5.24 App 资源编译及导出成功(有既有 CSS 注释警告) 本地 33 项支付测试通过,涵盖无弹窗、成功跳转、丢失回调、回调竞争、账号切换、页面栈满以及微信/支付宝成功页回归。尚未进行真机实付。 HBuilderX 5.24 App 资源编译及导出成功,仅有既有 CSS Autoprefixer 注释警告。 + +## 失败与杀进程恢复页面对齐(2026-09-09 第四次补丁) + +按用户要求直接复用微信、支付宝的支付失败页及其“确认”操作,不增加弹窗,不新增页面。 + +- SDK 取消/调用失败后,先查询支付结果;已确认支付成功仍进入成功页,其余本次未完成的支付进入现有失败页。 +- 支付时杀死 App,重新打开后读取原订单并查单:已支付进入成功页;已关闭/未完成进入失败页,不自动唤起 SDK。 +- 旧单关闭的点击直接结束在失败页,不在失败页背后同一次操作再创建新单;后续用户主动点击才继续新支付。 +- 页面跳转只结束本次客户端支付交互,不代表服务端订单被置为失败。未知/网络异常仍保留原订单号并用已有 Toast 提示待确认,后续到账仍可查到成功;不自动关单、退款或丢弃待查记录。 +- 同一进程中同一未完成订单只自动展示一次失败页,避免每次回前台又强制跳回。新的主动支付尝试重新计数。 +- 成功/失败共用导航封装,页面栈满时尝试 redirectTo;跳转失败保留恢复记录。微信、支付宝仍使用原来的成功/失败页面。 + +39 项前端测试通过,包含杀进程后新运行上下文恢复、取消/原生错误、离线恢复、失败跳转重试、迟到成功及微信/支付宝失败页回归。 +本次只改 JavaScript、测试和文档,不改后端、原生插件、SQL、接口或页面样式。尚未真机实付。 +HBuilderX 5.24 App 资源编译与导出成功,只有既有 CSS Autoprefixer 注释警告。 diff --git a/pages/home/home.vue b/pages/home/home.vue index b8cd847..d1e62d4 100644 --- a/pages/home/home.vue +++ b/pages/home/home.vue @@ -113,8 +113,6 @@ - 亲星星 - @@ -461,11 +459,6 @@ export default { // #endif }, methods: { - aaa() { - uni.navigateTo({ - url: '/pages/other_package/douyin_pay_test/douyin_pay_test' - }); - }, getScrollTop(e) { let top = 0; if (typeof e === "number") { diff --git a/pages/order_package/order_submit/order_submit2.vue b/pages/order_package/order_submit/order_submit2.vue deleted file mode 100644 index 7e76a5f..0000000 --- a/pages/order_package/order_submit/order_submit2.vue +++ /dev/null @@ -1,1528 +0,0 @@ - - - - - diff --git a/tests/douyin-integration.test.cjs b/tests/douyin-integration.test.cjs index f6c5e38..585a170 100644 --- a/tests/douyin-integration.test.cjs +++ b/tests/douyin-integration.test.cjs @@ -52,6 +52,7 @@ async function payContext(overrides = {}, app = true) { return loadModule('utils/douyin-pay.js', context, app); } 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 () => { let queries = 0; 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); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), 'order-1'); 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 () => { 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'); await ctx.resumeDouyinPay(); 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 () => { @@ -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:2}})}); 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(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}}, openDypay:(options,cb)=>{opened=options.payInfo.prepayid; cb({resultCode:'0'});}}); 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(resets,1); 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')}); assert.equal((await ctx.appDypayFun(payment)).state, 'error'); 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]); }); diff --git a/utils/douyin-pay.js b/utils/douyin-pay.js index 5f3477f..b6c4a62 100644 --- a/utils/douyin-pay.js +++ b/utils/douyin-pay.js @@ -5,12 +5,13 @@ 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 } from './payment-navigation.js'; +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) || {}; @@ -57,12 +58,25 @@ async function showState(state, key, orderNum) { 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 === 'paid' || state.state === 'closed') return; - uni.showToast({ title: state.state === 'failed' ? '支付未成功,请查看订单' - : state.message || '支付结果待确认,请稍后查看订单', icon: 'none', duration: 2500 }); + if (state.state !== 'pending') return; + uni.showToast({ title: state.message || '支付结果待确认,请稍后查看订单', icon: 'none', duration: 2500 }); } // 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('支付初始化失败,请稍后重试'); } // 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; @@ -136,6 +151,7 @@ async function executePayment(payment, key) { // Runs on the payment click BEFORE creating an order. Resume a live original transaction directly. export async function prepareDouyinPay(payway) { if (payway !== 'douyin') return true; + let paymentKey = ''; try { // #ifndef APP-PLUS throw new Error('抖音支付仅支持 Android/iOS App'); @@ -143,15 +159,17 @@ export async function prepareDouyinPay(payway) { // #ifdef APP-PLUS if (active || resuming) throw new Error('正在处理支付,请勿重复点击'); const key = accountKey(); + paymentKey = key; if (!key) throw new Error('请先登录后再支付'); const previous = uni.getStorageSync(key); if (previous) { + failureShown.delete(key); resuming = true; try { - const result = await continuePendingPayment(key, previous); - if (result.state !== 'closed' || key !== accountKey() || uni.getStorageSync(key)) return false; - if (!canOpenDypay()) throw new Error('请先安装或升级抖音客户端'); - return true; + await continuePendingPayment(key, previous); + // A completed/closed attempt has already entered its result page. Do not create + // another order behind that page during the same click. + return false; } finally { resuming = false; } } @@ -159,6 +177,11 @@ export async function prepareDouyinPay(payway) { return true; // #endif } 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' }); return false; } @@ -183,7 +206,9 @@ export async function appDypayFun(payment) { } return await executePayment(payment, key); } 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 || '支付结果待确认,请查看订单' }; } finally { active = false; } // #endif @@ -217,7 +242,11 @@ export async function resumeDouyinPay() { if (key === accountKey() && state.state !== 'pending') resetDypay(); 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; } // #endif } diff --git a/utils/payUtils.js b/utils/payUtils.js index 8a7e633..f46a3ab 100644 --- a/utils/payUtils.js +++ b/utils/payUtils.js @@ -1,5 +1,5 @@ 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"; /** @@ -128,11 +128,9 @@ export function jumpWayOk() { return jumpToPaymentSuccess(); } // 跳转到支付失败页面 -export function jumpWayError() { - uni.navigateTo({ - url: "/pages/order_package/order_submit_error/order_submit_error", - }); -} +export function jumpWayError() { + return jumpToPaymentFailure(); +} /** * 支付结果回调 * @param {Object} orderId 支付订单ID diff --git a/utils/payment-navigation.js b/utils/payment-navigation.js index 0e54c2b..1689c27 100644 --- a/utils/payment-navigation.js +++ b/utils/payment-navigation.js @@ -1,6 +1,13 @@ // Shared result pages for WeChat, Alipay and Douyin. Keep the existing page design. 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() : []; if (pages[pages.length - 1]?.route === url.slice(1)) return Promise.resolve(true); return new Promise(resolve => {