diff --git a/docs/douyin-pay-integration.md b/docs/douyin-pay-integration.md index ba69f93..0a41e84 100644 --- a/docs/douyin-pay-integration.md +++ b/docs/douyin-pay-integration.md @@ -98,3 +98,17 @@ npm run test:douyin 覆盖参数适配、旧调用入口、服务端确认、重 最终验证:27 项前端支付测试全部通过;后端 41 项选定支付测试通过,包含真实 H2 事务回滚验证。 HBuilderX 5.24 App 资源编译及导出成功(有既有 CSS 注释警告)。 资源导出通过不代表 Android/iOS 原生构建或真机交易已验证,发布前仍须完成验收。 + +## 与现有支付交互对齐(2026-09-09 第三次补丁) + +本节取代前文“显式确认/继续支付/重新支付弹窗”的交互说明,遵循用户要求,不新增确认弹窗。 + +- 用户点击支付即继续:原单有效时直接续付;渠道确认旧单关闭后,直接继续当前页面的下单请求。保留服务端防重复支付校验。 +- 微信、支付宝和抖音共用现有支付成功页 `pages/order_package/order_submit_ok/order_submit_ok`,不新建成功页,不仅显示成功 Toast。 +- 抖音正常回调、再次点击查到已支付、冷启动查到已支付,均在服务端确认后跳转成功页。 +- 从抖音返回 App 时,即使 SDK 回调丢失,也允许 onShow 查单结束等待;与迟到 SDK 回调竞争时只处理一次。查单失败或 pending 不跳成功、不解锁。 +- 页面栈满时使用 redirectTo 进入同一成功页;两种跳转均失败则保留待查记录,下次重试跳转,不再次付款。 +- 本次仅修改前端 JavaScript、测试及文档,不改后端、接口、SQL、原生插件和支付页面样式。仍依赖上一补丁的后端与原生插件 0.2.0。 + +本地 33 项支付测试通过,涵盖无弹窗、成功跳转、丢失回调、回调竞争、账号切换、页面栈满以及微信/支付宝成功页回归。尚未进行真机实付。 +HBuilderX 5.24 App 资源编译及导出成功,仅有既有 CSS Autoprefixer 注释警告。 diff --git a/tests/douyin-integration.test.cjs b/tests/douyin-integration.test.cjs index 7253eae..f6c5e38 100644 --- a/tests/douyin-integration.test.cjs +++ b/tests/douyin-integration.test.cjs @@ -30,10 +30,13 @@ function loadModule(file, context, app = true) { function uniMock() { const storage = new Map([['token', 'test-token'], ['user', { userId: 7 }]]); const messages = []; - return { storage, messages, getStorageSync: k => storage.get(k), setStorageSync: (k,v) => storage.set(k,v), + const navigations = []; + return { storage, messages, navigations, getStorageSync: k => storage.get(k), setStorageSync: (k,v) => storage.set(k,v), removeStorageSync: k => storage.delete(k), showToast: v => messages.push(v.title), $emit() {}, - showModal: options => options.success({ confirm: true }), - showLoading() {}, hideLoading() {}, navigateTo() {} }; + showModal: () => assert.fail('Douyin must not add payment confirmation modals'), + showLoading() {}, hideLoading() {}, + navigateTo: options => { navigations.push(options.url); options.success?.(); }, + redirectTo: options => { navigations.push(options.url); options.success?.(); } }; } const payment = { orderNum: 'order-1', douyin: { appId: 'app', mchId: 'mch', prepayId: 'prepay', packageValue: 'Sign=DYPay', nonceStr: 'nonce', timeStamp: '1780000000', sign: 'sig' } }; @@ -45,14 +48,16 @@ async function payContext(overrides = {}, app = true) { request: async options => options.url === '/payment/douyin/resume' ? ({ bizcode: 100, data: { state: 'paid', orderNum: options.data.orderNum } }) : ({ bizcode: 100, data: { state: 2 } }), ...overrides }; + loadModule('utils/payment-navigation.js', context, app); return loadModule('utils/douyin-pay.js', context, app); } +const successPage = '/pages/order_package/order_submit_ok/order_submit_ok'; 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} }; } }); await ctx.appDypayFun(payment); assert.equal(queries, 1); - assert.equal(ctx.uni.messages.at(-1), '支付成功'); + assert.deepEqual(ctx.uni.navigations, [successPage]); assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false); }); test('SDK success plus network failure remains recoverable, never success', async () => { @@ -60,11 +65,12 @@ 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, []); }); test('cancel still queries authoritative server state', async () => { const ctx = await payContext({ openDypay: (_, cb) => cb({ resultCode:'1' }) }); await ctx.appDypayFun(payment); - assert.equal(ctx.uni.messages.at(-1), '支付成功'); + assert.deepEqual(ctx.uni.navigations, [successPage]); }); test('old backend parameters never invoke native SDK', async () => { let calls = 0; @@ -123,6 +129,7 @@ test('account switch suppresses old account result and retains its pending recor ctx.request = async () => { ctx.uni.storage.set('user', {userId:8}); return {bizcode:100,data:{state:2}}; }; await ctx.appDypayFun(payment); assert.equal(ctx.uni.messages.includes('支付成功'), false); + assert.deepEqual(ctx.uni.navigations, []); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), 'order-1'); }); test('cold start queries saved order; unavailable app does not create order', async () => { @@ -130,7 +137,7 @@ test('cold start queries saved order; unavailable app does not create order', as assert.equal(await ctx.prepareDouyinPay('douyin'), false); ctx.uni.storage.set('tb-douyin:test-api:7', 'pending'); await ctx.resumeDouyinPay(); - assert.equal(ctx.uni.messages.at(-1), '支付成功'); + assert.deepEqual(ctx.uni.navigations, [successPage]); }); test('non-App excludes Douyin and never accesses native SDK', async () => { const ctx = await payContext({}, false); @@ -181,16 +188,14 @@ test('killed App resumes the original unpaid transaction, without creating anoth assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false); }); -test('cold start and declining continuation never open the SDK or erase pending state', async () => { +test('cold start never opens the SDK or asks for payment and preserves pending state', async () => { const ctx = await payContext({openDypay:()=>assert.fail('Unexpected SDK call'), queryUntilSettled: async()=>({state:'pending'})}); ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); ctx.uni.showModal = () => assert.fail('onShow must not ask for payment'); await ctx.resumeDouyinPay(); - ctx.request = async()=>({bizcode:100,data:{state:'unpaid',orderNum:payment.orderNum,payment}}); - ctx.uni.showModal = options => options.success({confirm:false}); - assert.equal(await ctx.prepareDouyinPay('douyin'), false); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum); + assert.deepEqual(ctx.uni.navigations, []); }); test('unknown, processing, network error and missing backend remain locked', async () => { @@ -234,16 +239,16 @@ test('closed old transaction unlocks a fresh server order, never reopens old par assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'),false); }); -test('declining new payment or switching accounts after closure never creates a payment', async () => { - for(const mode of ['cancel','account']) { - const ctx=await payContext({request:async()=>({bizcode:100,data:{state:'closed',orderNum:'expired-order'}})}); - ctx.uni.storage.set('tb-douyin:test-api:7','expired-order'); - ctx.uni.showModal=options=>{ - if(mode==='account') ctx.uni.storage.set('user',{userId:8}); - options.success({confirm:mode!=='cancel'}); - }; - assert.equal(await ctx.prepareDouyinPay('douyin'),false); - } +test('switching accounts during old-order closure cannot create a new payment', async () => { + const ctx=await payContext(); + ctx.request=async()=>{ + ctx.uni.storage.set('user',{userId:8}); + return {bizcode:100,data:{state:'closed',orderNum:'expired-order'}}; + }; + ctx.uni.storage.set('tb-douyin:test-api:7','expired-order'); + assert.equal(await ctx.prepareDouyinPay('douyin'),false); + assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),'expired-order'); + assert.deepEqual(ctx.uni.navigations, []); }); test('unconfirmed server state never resets the native lock and explains why', async () => { @@ -273,36 +278,112 @@ test('SDK rejection stays recoverable but no longer hides the native failure', a assert.equal(ctx.uni.messages.includes('支付成功'),false); }); -test('account switch during resume response or confirmation cannot invoke SDK', async () => { - for (const phase of ['response','confirmation']) { +test('account switch during resume response or native reset cannot invoke SDK', async () => { + for (const phase of ['response','reset']) { const ctx = await payContext({openDypay:()=>assert.fail('Old account reached SDK')}); ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); ctx.request = async()=> { if (phase === 'response') ctx.uni.storage.set('user',{userId:8}); return {bizcode:100,data:{state:'unpaid',orderNum:payment.orderNum,payment}}; }; - ctx.uni.showModal = options => {ctx.uni.storage.set('user',{userId:8}); options.success({confirm:true});}; + if (phase === 'reset') ctx.resetDypay = () => ctx.uni.storage.set('user',{userId:8}); await ctx.prepareDouyinPay('douyin'); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum); } }); test('concurrent clicks and onShow during continuation open only one native payment', async () => { - let confirm; + let done; let calls = 0; const ctx = await payContext({request: async()=>({bizcode:100,data:{state:'unpaid',orderNum:payment.orderNum,payment}}), queryUntilSettled: async()=>({state:'paid'}), - openDypay:(_,cb)=>{ calls++; cb({resultCode:'0'}); }}); + openDypay:(_,cb)=>{ calls++; done = cb; }}); ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); - ctx.uni.showModal = options => {confirm = options.success;}; const first = ctx.prepareDouyinPay('douyin'); await new Promise(resolve=>setImmediate(resolve)); assert.equal(await ctx.prepareDouyinPay('douyin'), false); await ctx.appDypayFun(payment); await ctx.resumeDouyinPay(); - confirm({confirm:true}); + done({resultCode:'0'}); await first; assert.equal(calls, 1); + assert.deepEqual(ctx.uni.navigations, [successPage]); +}); + +test('returning from Douyin with a missing callback completes via server query and navigates once', async () => { + let lateCallback; + const ctx = await payContext({openDypay:(_,cb)=>{ lateCallback = cb; }}); + const paying = ctx.appDypayFun(payment); + await ctx.resumeDouyinPay(); + assert.equal((await paying).state, 'paid'); + lateCallback({resultCode:'0'}); + await ctx.resumeDouyinPay(); + assert.deepEqual(ctx.uni.navigations, [successPage]); + assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false); +}); + +test('pending or failed foreground query never unlocks or navigates before the native callback', async () => { + for (const mode of ['pending', 'offline']) { + let done, resets = 0; + const ctx = await payContext({openDypay:(_,cb)=>{ done=cb; }, resetDypay:()=>resets++, + queryUntilSettled:async()=>{ if (mode === 'offline') throw new Error('offline'); return {state:'pending'}; }}); + const paying = ctx.appDypayFun(payment); + await ctx.resumeDouyinPay(); + assert.equal(resets, 0); + assert.deepEqual(ctx.uni.navigations, []); + assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum); + ctx.queryUntilSettled = async()=>({state:'paid'}); + done({resultCode:'0'}); + await paying; + assert.deepEqual(ctx.uni.navigations, [successPage]); + } +}); + +test('native callback racing with foreground query still navigates only once', async () => { + let done, finishQuery; + const ctx=await payContext({openDypay:(_,cb)=>{done=cb;}}); + const paying=ctx.appDypayFun(payment); + ctx.queryUntilSettled=()=>new Promise(resolve=>{finishQuery=resolve;}); + const foreground=ctx.resumeDouyinPay(); + ctx.queryUntilSettled=async()=>({state:'paid'}); + done({resultCode:'0'}); + await paying; + finishQuery({state:'paid'}); + await foreground; + assert.deepEqual(ctx.uni.navigations,[successPage]); +}); + +test('full page stack falls back to redirect; navigation failure remains retryable without another payment', async () => { + const ctx=await payContext(); + ctx.uni.navigateTo=options=>options.fail(); + await ctx.appDypayFun(payment); + assert.deepEqual(ctx.uni.navigations,[successPage]); + const failed=await payContext(); + failed.uni.navigateTo=options=>options.fail(); + failed.uni.redirectTo=options=>options.fail(); + await failed.appDypayFun(payment); + assert.equal(failed.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum); + failed.uni.redirectTo=options=>{ failed.uni.navigations.push(options.url); options.success(); }; + failed.openDypay=()=>assert.fail('Already paid order must not open SDK again'); + await failed.prepareDouyinPay('douyin'); + assert.deepEqual(failed.uni.navigations,[successPage]); + assert.equal(failed.uni.storage.has('tb-douyin:test-api:7'),false); +}); + +test('already visible success page is not pushed again', async () => { + const ctx=await payContext({getCurrentPages:()=>[{route:successPage.slice(1)}]}); + await ctx.appDypayFun(payment); + assert.deepEqual(ctx.uni.navigations,[]); + assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'),false); +}); + +test('WeChat and Alipay retain the same shared success page', async () => { + const ctx=await payContext({feedback:async()=>({bizcode:100}),console:{log(){},error(){}}}); + loadModule('utils/payUtils.js',ctx); + ctx.uni.requestPayment=options=>options.success({}); + ctx.appWxpayFun({type:'app'},1,'wechat-order'); + ctx.zfbPayFun({type:'app',orderStr:'test'},2,'alipay-order'); + assert.deepEqual(ctx.uni.navigations,[successPage,successPage]); }); test('resume refuses mismatched order, bad signed parameters and native initialization failure', async () => { diff --git a/utils/douyin-pay.js b/utils/douyin-pay.js index 47b6d23..5f3477f 100644 --- a/utils/douyin-pay.js +++ b/utils/douyin-pay.js @@ -5,9 +5,12 @@ 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'; let active = false; let resuming = false; +let inFlight = null; +let checkingForeground = false; function accountKey() { if (!uni.getStorageSync(TOKEN_NAME)) return ''; const user = uni.getStorageSync(USER_DATA) || {}; @@ -44,12 +47,21 @@ export function filterDouyinPayWays(ways) { return (ways || []).filter(item => item.type !== 'douyin'); // #endif } -function showState(state, key, orderNum) { +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; + } + } if (state.state !== 'pending' && uni.getStorageSync(key) === orderNum) uni.removeStorageSync(key); uni.$emit('douyin-payment-result', { orderNum, state: state.state }); - uni.showToast({ title: state.state === 'paid' ? '支付成功' : state.state === 'failed' - ? '支付未成功,请查看订单' : state.state === 'closed' ? '原支付已关闭,可重新下单' + if (state.state === 'paid' || state.state === 'closed') return; + uni.showToast({ title: state.state === 'failed' ? '支付未成功,请查看订单' : state.message || '支付结果待确认,请稍后查看订单', icon: 'none', duration: 2500 }); } @@ -65,7 +77,7 @@ async function continuePendingPayment(key, orderNum) { // 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(); - showState(state, key, orderNum); + await showState(state, key, orderNum); return state; } if (result.state !== 'unpaid') { @@ -76,21 +88,14 @@ async function continuePendingPayment(key, orderNum) { CHANNEL_PENDING: '抖音支付处理中,请稍后查看订单' }; const state = { state: 'pending', message: messages[result.reason] }; - showState(state, key, orderNum); + await showState(state, key, orderNum); return state; } if (result.payment?.orderNum !== orderNum) throw new Error('续付订单不匹配,请查看订单'); - // Validate before the confirmation dialog. Never store the SDK signature locally. + // 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(); - const confirmed = await new Promise(resolve => uni.showModal({ - title: '继续上一笔支付', - content: `上一笔抖音支付尚未完成,是否继续支付原订单?${result.payment.amount != null - ? `金额:¥${result.payment.amount}。` : ''}不会创建新订单。`, - confirmText: '继续支付', cancelText: '暂不支付', - success: res => resolve(res.confirm === true), fail: () => resolve(false) - })); - if (!confirmed || key !== accountKey()) return { state: 'pending' }; return executePayment(result.payment, key); } @@ -104,20 +109,31 @@ async function executePayment(payment, key) { } // Persist before entering native code so a killed process remains recoverable. uni.setStorageSync(key, payment.orderNum); - const nativeResult = await new Promise(resolve => openDypay({ payInfo, showLoading: true }, resolve)); - const state = 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)] || '支付未完成'},请查询订单后重试`; - } - showState(state, key, payment.orderNum); - return state; + 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 } -// Runs BEFORE creating an order. A pending record offers explicit continuation, not a new order. +// 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; try { @@ -134,13 +150,6 @@ export async function prepareDouyinPay(payway) { try { const result = await continuePendingPayment(key, previous); if (result.state !== 'closed' || key !== accountKey() || uni.getStorageSync(key)) return false; - const confirmed = await new Promise(resolve => uni.showModal({ - title: '原支付已关闭', - content: '上一笔支付已确认关闭。是否按当前页面重新发起支付?原商品订单若已过期,请返回商品页重新下单。', - confirmText: '重新支付', cancelText: '暂不支付', - success: res => resolve(res.confirm === true), fail: () => resolve(false) - })); - if (!confirmed || key !== accountKey() || uni.getStorageSync(key)) return false; if (!canOpenDypay()) throw new Error('请先安装或升级抖音客户端'); return true; } @@ -182,6 +191,22 @@ export async function appDypayFun(payment) { 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); @@ -190,7 +215,7 @@ export async function resumeDouyinPay() { try { const state = await query(pending); if (key === accountKey() && state.state !== 'pending') resetDypay(); - showState(state, key, pending); + await showState(state, key, pending); } catch (_) { /* Keep the order for a later onShow. Never infer a payment failure. */ } finally { resuming = false; } diff --git a/utils/payUtils.js b/utils/payUtils.js index 062e472..8a7e633 100644 --- a/utils/payUtils.js +++ b/utils/payUtils.js @@ -1,4 +1,5 @@ -import { appDypayFun } from './douyin-pay.js'; +import { appDypayFun } from './douyin-pay.js'; +import { jumpToPaymentSuccess } from './payment-navigation.js'; import { feedback } from "@/api/payment.js"; /** @@ -123,11 +124,9 @@ export const appDyPayFun = douyinPayFun; export const appDouyinPayFun = douyinPayFun; // 跳转到支付成功页面 -export function jumpWayOk() { - uni.navigateTo({ - url: "/pages/order_package/order_submit_ok/order_submit_ok", - }); -} +export function jumpWayOk() { + return jumpToPaymentSuccess(); +} // 跳转到支付失败页面 export function jumpWayError() { uni.navigateTo({ diff --git a/utils/payment-navigation.js b/utils/payment-navigation.js new file mode 100644 index 0000000..0e54c2b --- /dev/null +++ b/utils/payment-navigation.js @@ -0,0 +1,12 @@ +// 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'; + const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []; + if (pages[pages.length - 1]?.route === url.slice(1)) return Promise.resolve(true); + return new Promise(resolve => { + uni.navigateTo({ url, success: () => resolve(true), fail: () => { + // A full page stack must not leave a confirmed payment on the checkout screen. + uni.redirectTo({ url, success: () => resolve(true), fail: () => resolve(false) }); + } }); + }); +}