diff --git a/docs/douyin-pay-integration.md b/docs/douyin-pay-integration.md index ca0a8d7..ba69f93 100644 --- a/docs/douyin-pay-integration.md +++ b/docs/douyin-pay-integration.md @@ -79,3 +79,22 @@ npm run test:douyin 覆盖参数适配、旧调用入口、服务端确认、重 编译成功,所选 10 项测试通过(含新增 7 项续付测试)。 提交目标:后端 hashmall/hashmall 的 dev_douyin_tmp,App 为 charles/frontend-app 的 dev_codex。 尚未部署或进行真机实付;以上测试均使用模拟渠道,不产生真实交易。 + +## 过期关单及支付安全修复(2026-09-09 第二次补丁) + +本节覆盖前文旧续付补丁对“过期仍 pending”和“不关单”的行为描述。 + +- 后端先查单,过期且 NOTPAY 时请求渠道关单、再次确认,成功后结束本地支付与商品/充值订单。查单失败不放行。 +- `/payment/douyin/resume` 新增 reason:QUERY_FAILED、CLOSE_UNCONFIRMED、RECONCILIATION_REQUIRED、CHANNEL_PENDING;App 显示相应提示,不暴露后台密钥或完整渠道响应。 +- closed 解除缓存的旧支付单,用户确认后按当前页面重新请求支付;如果原商品订单已取消,需返回商品页重新下单。 +- 原生错误码不再被完全隐藏,但 SDK 错误/取消不能单独用于判断渠道订单已关闭。 +- 插件版本 0.2.0 新增 resetDypay。在服务端确认 paid/closed/unpaid 后重置 UTS/Swift 锁,恢复原生回调丢失场景。迟到回调不作为支付成功依据。 +- 续付请求超时 60 秒,查单 30 秒;其他请求默认值不变。 + +**本次修改了原生插件,必须重新打包 APK/IPA 或自定义基座;不要只对旧基座发布 WGT。** +先更新后端 frontend/paycenter/job/mqconsumer 及实际承载退款的服务,再更新 App。 +无 SQL 迁移。历史已关闭但已收款的异常单保留人工核对,不自动恢复发货或重复付款。 +前端 `npm run test:douyin` 新增过期旧单放行、取消/切换账号、明确诊断、原生锁恢复等用例;测试不产生真实交易。 +最终验证:27 项前端支付测试全部通过;后端 41 项选定支付测试通过,包含真实 H2 事务回滚验证。 +HBuilderX 5.24 App 资源编译及导出成功(有既有 CSS 注释警告)。 +资源导出通过不代表 Android/iOS 原生构建或真机交易已验证,发布前仍须完成验收。 diff --git a/tests/douyin-integration.test.cjs b/tests/douyin-integration.test.cjs index 6082a5e..7253eae 100644 --- a/tests/douyin-integration.test.cjs +++ b/tests/douyin-integration.test.cjs @@ -40,7 +40,7 @@ const payment = { orderNum: 'order-1', douyin: { appId: 'app', mchId: 'mch', pre async function payContext(overrides = {}, app = true) { const adapter = await import('../utils/douyin-pay-adapter.mjs'); const context = { uni: uniMock(), BASE_URL: 'test-api', TOKEN_NAME: 'token', USER_DATA: 'user', - ...adapter, canOpenDypay: () => true, initDypay: () => true, + ...adapter, canOpenDypay: () => true, initDypay: () => true, resetDypay: () => {}, openDypay: (_, cb) => cb({ resultCode: '0' }), request: async options => options.url === '/payment/douyin/resume' ? ({ bizcode: 100, data: { state: 'paid', orderNum: options.data.orderNum } }) @@ -214,12 +214,65 @@ 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'), state === 'closed'); 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 () => { + 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}}, + 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'),true); + assert.equal(resets,1); + const fresh={...payment,orderNum:'fresh-order',douyin:{...payment.douyin,prepayId:'fresh-prepay'}}; + await ctx.appDypayFun(fresh); + assert.equal(opened,'fresh-prepay'); + 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('unconfirmed server state never resets the native lock and explains why', async () => { + 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); + assert.match(ctx.uni.messages.at(-1),/查单失败/); + assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum); +}); + +test('unpaid server confirmation resets a missing native callback before reinitializing SDK', async () => { + const sequence=[]; + const ctx=await payContext({resetDypay:()=>sequence.push('reset'),initDypay:()=>{sequence.push('init');return true;}, + 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); + assert.deepEqual(sequence,['reset','init']); +}); + +test('SDK rejection stays recoverable but no longer hides the native failure', async () => { + const ctx=await payContext({openDypay:(_,cb)=>cb({resultCode:'103'}),queryUntilSettled:async()=>({state:'pending'})}); + await ctx.appDypayFun(payment); + assert.match(ctx.uni.messages.at(-1),/原生支付仍在处理中/); + assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'),payment.orderNum); + 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']) { const ctx = await payContext({openDypay:()=>assert.fail('Old account reached SDK')}); diff --git a/uni_modules/tb-douyin-pay/package.json b/uni_modules/tb-douyin-pay/package.json index 22e7bf9..b019b2a 100644 --- a/uni_modules/tb-douyin-pay/package.json +++ b/uni_modules/tb-douyin-pay/package.json @@ -1,7 +1,7 @@ { "id": "tb-douyin-pay", "displayName": "TB 抖音支付", - "version": "0.1.0", + "version": "0.2.0", "description": "独立实现的抖音支付官方 SDK UTS 桥接", "engines": { "HBuilderX": ">=4.36.0" }, "uni_modules": { diff --git a/uni_modules/tb-douyin-pay/utssdk/app-android/index.uts b/uni_modules/tb-douyin-pay/utssdk/app-android/index.uts index 7c08c56..214bcf8 100644 --- a/uni_modules/tb-douyin-pay/utssdk/app-android/index.uts +++ b/uni_modules/tb-douyin-pay/utssdk/app-android/index.uts @@ -1,5 +1,7 @@ import { InitOptions, PayOptions, PayCallback } from '../interface.uts' -import { configure, invoke } from '../common.uts' +import { configure, invoke, resetPending } from '../common.uts' + +export function resetDypay(): void { resetPending() } export function initDypay(options: InitOptions): boolean { if (!configure(options.appId)) return false diff --git a/uni_modules/tb-douyin-pay/utssdk/app-ios/TbDouyinPayNative.swift b/uni_modules/tb-douyin-pay/utssdk/app-ios/TbDouyinPayNative.swift index c03b191..85c82d0 100644 --- a/uni_modules/tb-douyin-pay/utssdk/app-ios/TbDouyinPayNative.swift +++ b/uni_modules/tb-douyin-pay/utssdk/app-ios/TbDouyinPayNative.swift @@ -12,6 +12,13 @@ public class TbDouyinPayNative { public static func available() -> Bool { DypayAPI.canOpenDypay() } + public static func resetPending() { + DispatchQueue.main.async { + generation = UUID() + pending = nil + } + } + public static func pay(_ payload: String, _ done: @escaping (String) -> Void) { DispatchQueue.main.async { guard pending == nil else { @@ -34,8 +41,8 @@ public class TbDouyinPayNative { DypayAPI.openDypay(withInfo: info, from: controller) { result in finish(result as? [AnyHashable: Any], ticket) } - // Keep a native lock after timeout: a late URL callback cannot identify a new order. - // The host must query the pending order rather than immediately initiate another pay. + // Only authenticated server reconciliation can release a missing callback's lock. + // URL callbacks are hints, never payment success; the host always queries its saved order. } } diff --git a/uni_modules/tb-douyin-pay/utssdk/app-ios/index.uts b/uni_modules/tb-douyin-pay/utssdk/app-ios/index.uts index dc94060..80ac619 100644 --- a/uni_modules/tb-douyin-pay/utssdk/app-ios/index.uts +++ b/uni_modules/tb-douyin-pay/utssdk/app-ios/index.uts @@ -1,7 +1,12 @@ import { UIApplication } from 'UIKit' import { URL, NSUserActivity } from 'Foundation' import { InitOptions, PayOptions, PayCallback } from '../interface.uts' -import { configure, invoke } from '../common.uts' +import { configure, invoke, resetPending } from '../common.uts' + +export function resetDypay(): void { + resetPending() + TbDouyinPayNative.resetPending() +} export function initDypay(options: InitOptions): boolean { if (options.callbackScheme.trim().length == 0 || !configure(options.appId)) return false diff --git a/uni_modules/tb-douyin-pay/utssdk/common.uts b/uni_modules/tb-douyin-pay/utssdk/common.uts index e811e36..e25dda4 100644 --- a/uni_modules/tb-douyin-pay/utssdk/common.uts +++ b/uni_modules/tb-douyin-pay/utssdk/common.uts @@ -4,6 +4,12 @@ let appId = '' let busy = false let sequence = 0 +// Host calls only after authenticated server reconciliation of the saved transaction. +export function resetPending(): void { + busy = false + sequence += 1 +} + export function configure(id: string): boolean { if (busy || id.trim().length == 0) return false appId = id diff --git a/utils/douyin-pay.js b/utils/douyin-pay.js index d103951..47b6d23 100644 --- a/utils/douyin-pay.js +++ b/utils/douyin-pay.js @@ -1,5 +1,5 @@ // #ifdef APP-PLUS -import { initDypay, canOpenDypay, openDypay } from '@/uni_modules/tb-douyin-pay'; +import { initDypay, canOpenDypay, openDypay, resetDypay } from '@/uni_modules/tb-douyin-pay'; // #endif import request from './request.js'; import { BASE_URL } from './config.js'; @@ -16,7 +16,7 @@ function accountKey() { } async function query(orderNum) { return queryUntilSettled(async options => { - const response = await request({ ...options, isShowLoading: false }); + const response = await request({ ...options, isShowLoading: false, timeout: 30000 }); if (response?.bizcode !== 100) throw new Error('查询支付结果失败'); return response.data; }, orderNum); @@ -49,31 +49,40 @@ function showState(state, key, orderNum) { 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' - ? '支付未成功,请查看订单' : '支付结果待确认,请稍后查看订单', icon: 'none', duration: 2500 }); + ? '支付未成功,请查看订单' : state.state === 'closed' ? '原支付已关闭,可重新下单' + : state.message || '支付结果待确认,请稍后查看订单', icon: 'none', duration: 2500 }); } // Called only by a user payment action, never by onShow. Renew the SAME transaction on the server. async function continuePendingPayment(key, orderNum) { const response = await request({ url: '/payment/douyin/resume', method: 'POST', - data: { orderNum }, isShowLoading: false }); + data: { orderNum }, isShowLoading: false, timeout: 60000 }); 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. - const state = result.state === 'paid' ? await query(orderNum) : { state: 'failed' }; + const state = result.state === 'paid' ? await query(orderNum) : { state: 'closed' }; + if (key === accountKey() && state.state !== 'pending') resetDypay(); showState(state, key, orderNum); return state; } if (result.state !== 'unpaid') { - const state = { state: 'pending' }; + const messages = { + QUERY_FAILED: '抖音查单失败,暂不能重新支付,请稍后重试', + CLOSE_UNCONFIRMED: '旧支付关单尚未确认,请稍后重试', + RECONCILIATION_REQUIRED: '订单状态需核对,请联系客服,勿重复付款', + CHANNEL_PENDING: '抖音支付处理中,请稍后查看订单' + }; + const state = { state: 'pending', message: messages[result.reason] }; 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. toSdkPayInfo(result.payment); + resetDypay(); const confirmed = await new Promise(resolve => uni.showModal({ title: '继续上一笔支付', content: `上一笔抖音支付尚未完成,是否继续支付原订单?${result.payment.amount != null @@ -95,8 +104,14 @@ async function executePayment(payment, key) { } // Persist before entering native code so a killed process remains recoverable. uni.setStorageSync(key, payment.orderNum); - await new Promise(resolve => openDypay({ payInfo, showLoading: true }, resolve)); + 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; // #endif @@ -116,9 +131,20 @@ export async function prepareDouyinPay(payway) { const previous = uni.getStorageSync(key); if (previous) { resuming = true; - try { await continuePendingPayment(key, previous); } + 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; + } finally { resuming = false; } - return false; } if (!canOpenDypay()) throw new Error('请先安装或升级抖音客户端'); return true; @@ -161,7 +187,11 @@ export async function resumeDouyinPay() { const pending = key && uni.getStorageSync(key); if (!pending) return; resuming = true; - try { showState(await query(pending), key, pending); } + try { + const state = await query(pending); + if (key === accountKey() && state.state !== 'pending') resetDypay(); + showState(state, key, pending); + } catch (_) { /* Keep the order for a later onShow. Never infer a payment failure. */ } finally { resuming = false; } // #endif diff --git a/utils/request.js b/utils/request.js index ea1d193..f0335b4 100644 --- a/utils/request.js +++ b/utils/request.js @@ -31,7 +31,8 @@ function request(options) { method = "GET", data = {}, headers = {}, - isShowLoading = true, + isShowLoading = true, + timeout = 15000, } = options; const token = getStorageFun(TOKEN_NAME); @@ -60,7 +61,7 @@ function request(options) { ...defaultHeaders, ...headers, }, - timeout: 15000, + timeout, success: (res) => { if (isShowLoading) { hideLoading();