diff --git a/docs/douyin-pay-integration.md b/docs/douyin-pay-integration.md index 7283b6e..a5090a7 100644 --- a/docs/douyin-pay-integration.md +++ b/docs/douyin-pay-integration.md @@ -128,6 +128,19 @@ HBuilderX 5.24 App 资源编译及导出成功,仅有既有 CSS Autoprefixer 本次只改 JavaScript、测试和文档,不改后端、原生插件、SQL、接口或页面样式。尚未真机实付。 HBuilderX 5.24 App 资源编译与导出成功,只有既有 CSS Autoprefixer 注释警告。 +## 当前订单与缓存旧单隔离(2026-09-10) + +本节覆盖前文“点击支付先恢复账号缓存旧单”的说明。原逻辑会在取消 200 元订单后,购买其他 0.2 元商品时再次唤起 200 元旧单,必须移除。 + +- 与微信/支付宝一致,`prepareDouyinPay` 仅检查 App、登录、客户端可用性及正在进行的调用,不从账号缓存中选订单,不续付、不关单、不跳旧单结果页。 +- 商品结算、充值、订单详情仍分别调用现有业务接口,以本次接口返回的数据调用 `appDypayFun`,旧单不再拦截当前下单。 +- 只有当前响应 `payment.orderNum` 与缓存订单号完全一致时才查单续付;缓存属于另一单时,验证当前签名参数,释放残留客户端回调,再调用当前订单的 SDK 参数。金额和签名不在客户端改写。 +- 成功/失败继续使用微信/支付宝的现有结果页,不新增弹窗。实际尚在运行的 SDK 调用仍防重复点击;迟到的旧回调不得处理新订单。 +- 缓存只用于最近一次支付的 App 恢复,不是用户待支付订单清单。新支付发起后恢复记录指向新订单;旧订单仍保留在服务端,由回调、定时查单、订单详情处理,不自动关单或标记失败。若新参数无效/初始化失败,旧恢复记录不覆盖。 + +验证:`node --test tests/douyin-integration.test.cjs tests/douyin-uts.test.cjs`(需设置下节所述 HBUILDERX_HOME)51 项通过。新增测试经过真实前端 API 包装函数验证商品结算、充值和订单补付的当前响应,SDK/服务端均使用模拟数据,不产生真实交易。HBuilderX 5.24 App 资源编译、导出通过,仅有既有 CSS 注释警告。 +本次仅修改 `utils/douyin-pay.js`、回归测试和文档,无后端、SQL、接口、原生插件变更。仍需真机验收“200 元取消 → 0.2 元支付”的收银台金额与订单归属;不能把模拟测试当作实付验证。 + ## 双端 UTS 编译兼容(2026-09-10,插件 0.2.1) 基于用户确认的远端 `dev_codex` / `3e2e182`,不改变支付页面或后端逻辑。 diff --git a/tests/douyin-integration.test.cjs b/tests/douyin-integration.test.cjs index 585a170..0a7504b 100644 --- a/tests/douyin-integration.test.cjs +++ b/tests/douyin-integration.test.cjs @@ -19,7 +19,7 @@ function preprocess(source, app) { } function loadModule(file, context, app = true) { const source = preprocess(fs.readFileSync(path.join(root, file), 'utf8'), app) - .replace(/^import .*;\r?$/gm, '') + .replace(/^import [^\r\n]*;[^\S\r\n]*(?:\/\/[^\r\n]*)?\r?$/gm, '') .replace(/export (async function|function)/g, '$1') .replace(/export const /g, 'const ') .replace(/export default request;/g, ''); @@ -167,7 +167,7 @@ test('request terminates on HTTP500, business500, bad JSON, auth failure and off } }); -test('killed App resumes the original unpaid transaction, without creating another order', async () => { +test('killed App only resumes the original transaction when the current response selects that same order', async () => { const ctx = await payContext(); // This is exactly the legacy persisted format: only an order number survives process death. ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); @@ -182,8 +182,10 @@ test('killed App resumes the original unpaid transaction, without creating anoth }; ctx.openDypay = (options, cb) => { opened++; assert.equal(options.payInfo.prepayid, 'prepay'); cb({resultCode:'0'}); }; ctx.queryUntilSettled = async () => ({state:'paid'}); - // Returning false intentionally stops the caller from creating a NEW order after resuming. - assert.equal(await ctx.prepareDouyinPay('douyin'), false); + assert.equal(await ctx.prepareDouyinPay('douyin'), true); + assert.equal(opened, 0); + assert.deepEqual(urls, []); + await ctx.appDypayFun(payment); assert.equal(opened, 1); assert.deepEqual(urls, ['/payment/douyin/resume']); assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'), false); @@ -199,7 +201,7 @@ test('cold start never opens the SDK or asks for payment and preserves pending s assert.deepEqual(ctx.uni.navigations, [failurePage]); }); -test('unknown, processing, network error and missing backend remain locked', async () => { +test('retry of the SAME order remains blocked on unknown, processing, network error or missing backend', async () => { for (const mode of ['pending','NEW_STATE','offline','missing']) { const ctx = await payContext({openDypay:()=>assert.fail('Unknown result must not open SDK'), request: async()=> { @@ -208,7 +210,8 @@ test('unknown, processing, network error and missing backend remain locked', asy return {bizcode:100,data:{state:mode,orderNum:payment.orderNum}}; }}); ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); - assert.equal(await ctx.prepareDouyinPay('douyin'), false); + assert.equal(await ctx.prepareDouyinPay('douyin'), true); + await ctx.appDypayFun(payment); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum); } }); @@ -220,38 +223,41 @@ 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'), false); + assert.equal(await ctx.prepareDouyinPay('douyin'), true); + await ctx.appDypayFun(payment); 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); } }); -test('closed old transaction unlocks a fresh server order, never reopens old parameters', async () => { +test('a different current order does not resume or close the cached old transaction', async () => { let resets=0, opened; - const ctx=await payContext({resetDypay:()=>resets++, request:async options => options.url === '/payment/douyin/resume' - ? {bizcode:100,data:{state:'closed',orderNum:'expired-order'}} : {bizcode:100,data:{state:2}}, + const ctx=await payContext({resetDypay:()=>resets++, request:async options => { + assert.equal(options.url, '/payment/queryResult'); + assert.equal(options.data.orderNum, 'fresh-order'); + return {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); + assert.deepEqual(ctx.uni.navigations,[]); + assert.equal(resets,0); const fresh={...payment,orderNum:'fresh-order',douyin:{...payment.douyin,prepayId:'fresh-prepay'}}; await ctx.appDypayFun(fresh); + assert.equal(resets,1); assert.equal(opened,'fresh-prepay'); assert.equal(ctx.uni.storage.has('tb-douyin:test-api:7'),false); }); -test('switching accounts during old-order closure cannot create a new payment', async () => { +test('switching accounts during same-order reconciliation 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); + await ctx.appDypayFun({...payment,orderNum:'expired-order'}); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),'expired-order'); assert.deepEqual(ctx.uni.navigations, []); }); @@ -260,7 +266,7 @@ test('unconfirmed server state never resets the native lock and explains why', a const ctx=await payContext({resetDypay:()=>assert.fail('Unconfirmed native reset'), request:async()=>({bizcode:100,data:{state:'pending',reason:'QUERY_FAILED',orderNum:payment.orderNum}})}); ctx.uni.storage.set('tb-douyin:test-api:7',payment.orderNum); - assert.equal(await ctx.prepareDouyinPay('douyin'),false); + await ctx.appDypayFun(payment); assert.match(ctx.uni.messages.at(-1),/查单失败/); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum); }); @@ -271,7 +277,7 @@ test('unpaid server confirmation resets a missing native callback before reiniti request:async()=>({bizcode:100,data:{state:'unpaid',orderNum:payment.orderNum,payment}}), queryUntilSettled:async()=>({state:'pending'})}); ctx.uni.storage.set('tb-douyin:test-api:7',payment.orderNum); - assert.equal(await ctx.prepareDouyinPay('douyin'),false); + await ctx.appDypayFun(payment); assert.deepEqual(sequence,['reset','init']); }); @@ -292,7 +298,7 @@ test('account switch during resume response or native reset cannot invoke SDK', return {bizcode:100,data:{state:'unpaid',orderNum:payment.orderNum,payment}}; }; if (phase === 'reset') ctx.resetDypay = () => ctx.uni.storage.set('user',{userId:8}); - await ctx.prepareDouyinPay('douyin'); + await ctx.appDypayFun(payment); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum); } }); @@ -304,7 +310,7 @@ test('concurrent clicks and onShow during continuation open only one native paym queryUntilSettled: async()=>({state:'paid'}), openDypay:(_,cb)=>{ calls++; done = cb; }}); ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum); - const first = ctx.prepareDouyinPay('douyin'); + const first = ctx.appDypayFun(payment); await new Promise(resolve=>setImmediate(resolve)); assert.equal(await ctx.prepareDouyinPay('douyin'), false); await ctx.appDypayFun(payment); @@ -370,7 +376,7 @@ test('full page stack falls back to redirect; navigation failure remains retryab 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'); + await failed.appDypayFun(payment); assert.deepEqual(failed.uni.navigations,[successPage]); assert.equal(failed.uni.storage.has('tb-douyin:test-api:7'),false); }); @@ -401,7 +407,7 @@ test('resume refuses mismatched order, bad signed parameters and native initiali if (mode === 'init') ctx.initDypay = ()=>false; ctx.request = async()=>({bizcode:100,data:{state:'unpaid', orderNum:mode === 'outer-order' ? 'different' : payment.orderNum,payment:resumePayment}}); - await ctx.prepareDouyinPay('douyin'); + await ctx.appDypayFun(payment); assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum); } }); @@ -479,3 +485,117 @@ test('WeChat and Alipay failures retain the same shared failure page', async () ctx.zfbPayFun({type:'app'},2,'ali'); assert.deepEqual(ctx.uni.navigations,[failurePage,failurePage]); }); + +test('cancel 200 yuan then buy 0.2 yuan uses the current checkout response, never the cached 200 yuan order', async () => { + const oldOrder = {...payment, orderNum:'order-200', douyin:{...payment.douyin, prepayId:'prepay-200', sign:'signature-200'}}; + const current = {...payment, orderNum:'order-0.2', douyin:{...payment.douyin, prepayId:'prepay-0.2', sign:'signature-0.2'}}; + const opened = [], queried = [], requested = []; + let lateOldCallback; + const ctx = await payContext({ + request: async options => { + requested.push(options.url); + assert.equal(options.url, '/cart/settlementOrder'); + assert.equal(options.data.goodsId, 'product-0.2'); + return {bizcode:100, data:current}; + }, + queryUntilSettled: async (_, orderNum) => { queried.push(orderNum); return {state:orderNum === 'order-200' ? 'pending' : 'paid'}; }, + openDypay: (options, cb) => { + opened.push(options.payInfo); + if (options.payInfo.prepayid === 'prepay-200') { lateOldCallback = cb; cb({resultCode:'1'}); } + else cb({resultCode:'0'}); + } + }); + await ctx.appDypayFun(oldOrder); + assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), 'order-200'); + assert.deepEqual(ctx.uni.navigations, [failurePage]); + // Same sequence as checkout: preflight -> current business API -> SDK. + assert.equal(await ctx.prepareDouyinPay('douyin'), true); + assert.equal(opened.length, 1); + loadModule('api/cart.js', ctx); + const response = await ctx.settlementOrder({goodsId:'product-0.2',payway:'douyin'}); + await ctx.appDypayFun(response.data); + lateOldCallback({resultCode:'0'}); + assert.deepEqual(requested, ['/cart/settlementOrder']); + assert.deepEqual(opened.map(v=>v.prepayid), ['prepay-200','prepay-0.2']); + assert.equal(opened[1].sign, 'signature-0.2'); + assert.deepEqual(queried, ['order-200','order-0.2']); + assert.deepEqual(ctx.uni.navigations, [failurePage,successPage]); +}); + +test('new purchase, recharge and existing-order payment all ignore an unrelated cached order', async () => { + for (const [entry, file, method, url] of [ + ['purchase','api/cart.js','settlementOrder','/cart/settlementOrder'], + ['recharge','api/finance.js','recharge','/finance/recharge'], + ['existing-order','api/order.js','payment','/order/payment'] + ]) { + const current={...payment,orderNum:entry,douyin:{...payment.douyin,prepayId:entry}}; + const ctx = await payContext({request:async options=>{ + assert.equal(options.url,url); + assert.equal(options.data.payway,'douyin'); + return {bizcode:100,data:current}; + }, + queryUntilSettled:async(_, orderNum)=>{assert.equal(orderNum, entry);return {state:'pending'};}}); + ctx.uni.storage.set('tb-douyin:test-api:7', 'old-200'); + let received; + ctx.openDypay = (options, cb)=>{received=options.payInfo;cb({resultCode:'1'});}; + assert.equal(await ctx.prepareDouyinPay('douyin'), true); + assert.deepEqual(ctx.uni.navigations, []); + assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), 'old-200'); + loadModule(file,ctx); + const response=await ctx[method]({payway:'douyin'}); + await ctx.appDypayFun(response.data); + assert.equal(received.prepayid, entry); + assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), entry); + } +}); + +test('legacy entry also pays the supplied order rather than a cached order', async () => { + const ctx=await payContext({request:()=>assert.fail('Unrelated resume'), queryUntilSettled:async()=>({state:'paid'})}); + ctx.uni.storage.set('tb-douyin:test-api:7','old-200'); + loadModule('utils/payUtils.js',ctx); + let opened; + ctx.openDypay=(options,cb)=>{opened=options.payInfo.prepayid;cb({resultCode:'0'});}; + await ctx.douyinPayFun({...payment.douyin,prepayId:'current-0.2'},2,'current-order'); + assert.equal(opened,'current-0.2'); +}); + +test('bad current parameters never fall back to an unrelated old order or reset its native session', async () => { + const ctx=await payContext({request:()=>assert.fail('Old-order fallback'), + resetDypay:()=>assert.fail('Invalid response must not reset native'),openDypay:()=>assert.fail('Invalid payment')}); + ctx.uni.storage.set('tb-douyin:test-api:7','old-200'); + assert.equal(await ctx.prepareDouyinPay('douyin'),true); + assert.equal((await ctx.appDypayFun({orderNum:'new',douyin:{appId:'app'}})).state,'error'); + assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),'old-200'); +}); + +test('restart can reconcile the last attempt but a later new checkout cannot reopen it', async () => { + const ctx=await payContext({request:()=>assert.fail('Old resume'),queryUntilSettled:async()=>({state:'pending'})}); + ctx.uni.storage.set('tb-douyin:test-api:7','old-200'); + await ctx.resumeDouyinPay(); + assert.equal(await ctx.prepareDouyinPay('douyin'),true); + let opened; + ctx.openDypay=(options,cb)=>{opened=options.payInfo.prepayid;cb({resultCode:'1'});}; + await ctx.appDypayFun({...payment,orderNum:'new-0.2',douyin:{...payment.douyin,prepayId:'new-0.2'}}); + assert.equal(opened,'new-0.2'); +}); + +test('an actually running SDK payment still blocks a second order until its callback completes', async () => { + let callback, calls=0; + const ctx=await payContext({openDypay:(_,cb)=>{calls++;callback=cb;},queryUntilSettled:async()=>({state:'pending'})}); + const first=ctx.appDypayFun(payment); + assert.equal(await ctx.prepareDouyinPay('douyin'),false); + await ctx.appDypayFun({...payment,orderNum:'different'}); + assert.equal(calls,1); + callback({resultCode:'1'}); + await first; + assert.equal(await ctx.prepareDouyinPay('douyin'),true); +}); + +test('native reset for a different order cannot pay after an account switch', async () => { + const ctx=await payContext({openDypay:()=>assert.fail('Wrong account'), + resetDypay:()=>ctx.uni.storage.set('user',{userId:8})}); + ctx.uni.storage.set('tb-douyin:test-api:7','old-200'); + await ctx.appDypayFun(payment); + assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),'old-200'); + assert.equal(ctx.uni.storage.has('tb-douyin:test-api:8'),false); +}); diff --git a/utils/douyin-pay.js b/utils/douyin-pay.js index b6c4a62..c1eed4c 100644 --- a/utils/douyin-pay.js +++ b/utils/douyin-pay.js @@ -79,7 +79,8 @@ async function showState(state, key, orderNum) { 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. +// 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 }); @@ -148,10 +149,10 @@ async function executePayment(payment, key) { // #endif } -// Runs on the payment click BEFORE creating an order. Resume a live original transaction directly. +// 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; - let paymentKey = ''; try { // #ifndef APP-PLUS throw new Error('抖音支付仅支持 Android/iOS App'); @@ -159,29 +160,11 @@ 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 { - 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; } - } if (!canOpenDypay()) throw new Error('请先安装或升级抖音客户端'); 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; } @@ -200,10 +183,16 @@ export async function appDypayFun(payment) { try { if (!key || !payment.orderNum) throw new Error('登录身份或支付订单编号缺失'); const previous = uni.getStorageSync(key); - if (previous) { - // Even legacy callers must not open a different payment while the saved one is unresolved. - return await continuePendingPayment(key, previous); + 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()) {