fix: 支持关闭App后续付抖音原订单
This commit is contained in:
@@ -36,6 +36,7 @@ iOS SDK 位于 utssdk/app-ios/Frameworks/DypaySDK.xcframework。
|
||||
|
||||
仅服务端 /payment/queryResult 返回 state=2 才提示支付成功。
|
||||
SDK 成功或跳回 App 本身不作为到账依据。结果未知时保留待查订单并阻止重复发起。
|
||||
人工续付接口返回 paid 时仍走原查单接口,以便补同步业务订单;closed 只解除本地待查记录,不标记成功。
|
||||
测试页不再提供模拟 pay_token 链接,不在页面日志输出完整签名。
|
||||
H5/小程序不会调用原生 App SDK。
|
||||
|
||||
@@ -49,3 +50,32 @@ HBuilderX 5.24 执行 App appResource 导出成功,产物位于 unpackage/reso
|
||||
|
||||
npm run test:douyin 覆盖参数适配、旧调用入口、服务端确认、重复支付及异常恢复。
|
||||
这些自动化测试使用模拟 SDK/接口,不会产生真实交易,不能替代完整原生包与真机验收。
|
||||
|
||||
## 关闭 App 后继续未支付订单(2026-09-09 补丁)
|
||||
|
||||
旧实现把保存的订单一直当作“结果未知”,只查单不允许重新打开收银台。
|
||||
现在冷启动仍然只查单;用户再次点击抖音支付时,调用
|
||||
`POST /payment/douyin/resume`(表单参数 `orderNum`),由当前登录身份校验订单归属。
|
||||
|
||||
- 服务端确认 `NOTPAY` 且本地订单仍可支付、未过期:复用原支付订单号、金额、到期时间,
|
||||
获取新的 SDK 签名参数;用户确认“继续上一笔支付”后打开收银台。
|
||||
- 返回 `paid` 或 `closed`:不调 SDK,只清理该账户对应的本地待查记录。
|
||||
- `USERPAYING`、未知渠道状态、查单失败、续付接口不可用:仍保留记录,不创建新支付。
|
||||
- 取消弹窗、账号切换、重复点击:不会发起第二笔支付。
|
||||
- 只保存原订单号,不缓存签名;兼容修复前已保存的订单号,覆盖购物及充值。
|
||||
|
||||
接口返回 `data = { state, orderNum, payment }`,其中 state 为 unpaid/paid/closed/pending,
|
||||
只有 unpaid 才含 payment(与原 /order/payment 的 SDK 参数结构一致)。
|
||||
续付接口不创建支付订单、不重新扣抵扣券,也不会自动取消、退款或更改订单到期时间。
|
||||
|
||||
**发布顺序:先部署后端续付接口,再更新 App。只更新 App 无法解除旧后端的锁定。**
|
||||
本次无 SQL、无新增密钥配置。现有原生插件未变化,但仍需使用已包含 SDK 的安装包。
|
||||
|
||||
本补丁涉及 App 的 `utils/douyin-pay.js`、`tests/douyin-integration.test.cjs` 及本文档;
|
||||
后端涉及 `DouyinPaymentResumeService`、`DouyinPayResumeDto`、`DouyinPayService`、
|
||||
`QueryOrderResult`、`PayController` 和 `DouyinPaymentResumeServiceTest`。
|
||||
本地验证:`npm run test:douyin` 的 22 项测试通过;后端
|
||||
`mvn -o -pl hashmall-frontend -am test -Dtest=DouyinPaymentResumeServiceTest,DouyinPayInfoDtoTest,DouyinAppPaySignerTest -Dsurefire.failIfNoSpecifiedTests=false`
|
||||
编译成功,所选 10 项测试通过(含新增 7 项续付测试)。
|
||||
提交目标:后端 hashmall/hashmall 的 dev_douyin_tmp,App 为 charles/frontend-app 的 dev_codex。
|
||||
尚未部署或进行真机实付;以上测试均使用模拟渠道,不产生真实交易。
|
||||
|
||||
@@ -32,6 +32,7 @@ function uniMock() {
|
||||
const messages = [];
|
||||
return { storage, messages, 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() {} };
|
||||
}
|
||||
const payment = { orderNum: 'order-1', douyin: { appId: 'app', mchId: 'mch', prepayId: 'prepay',
|
||||
@@ -41,7 +42,9 @@ async function payContext(overrides = {}, app = true) {
|
||||
const context = { uni: uniMock(), BASE_URL: 'test-api', TOKEN_NAME: 'token', USER_DATA: 'user',
|
||||
...adapter, canOpenDypay: () => true, initDypay: () => true,
|
||||
openDypay: (_, cb) => cb({ resultCode: '0' }),
|
||||
request: async () => ({ bizcode: 100, data: { state: 2 } }), ...overrides };
|
||||
request: async options => options.url === '/payment/douyin/resume'
|
||||
? ({ bizcode: 100, data: { state: 'paid', orderNum: options.data.orderNum } })
|
||||
: ({ bizcode: 100, data: { state: 2 } }), ...overrides };
|
||||
return loadModule('utils/douyin-pay.js', context, app);
|
||||
}
|
||||
test('SDK success is confirmed with server before showing success', async () => {
|
||||
@@ -155,3 +158,117 @@ test('request terminates on HTTP500, business500, bad JSON, auth failure and off
|
||||
await assert.rejects(ctx.request({url:'/payment/queryResult'}));
|
||||
}
|
||||
});
|
||||
|
||||
test('killed App resumes the original unpaid transaction, without creating another 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);
|
||||
ctx.queryUntilSettled = async () => ({state:'pending'});
|
||||
await ctx.resumeDouyinPay();
|
||||
let opened = 0;
|
||||
const urls = [];
|
||||
ctx.request = async options => {
|
||||
urls.push(options.url);
|
||||
assert.equal(options.data.orderNum, payment.orderNum);
|
||||
return {bizcode:100, data:{state:'unpaid', orderNum:payment.orderNum, payment}};
|
||||
};
|
||||
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(opened, 1);
|
||||
assert.deepEqual(urls, ['/payment/douyin/resume']);
|
||||
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 () => {
|
||||
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);
|
||||
});
|
||||
|
||||
test('unknown, processing, network error and missing backend remain locked', 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()=> {
|
||||
if (mode === 'offline') throw new Error('offline');
|
||||
if (mode === 'missing') return {bizcode:404};
|
||||
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(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum);
|
||||
}
|
||||
});
|
||||
|
||||
test('terminal server result clears saved order without payment and allows the next action', async () => {
|
||||
for (const state of ['paid','closed']) {
|
||||
const ctx = await payContext({openDypay:()=>assert.fail('Terminal order reached SDK'),
|
||||
request: async options => options.url === '/payment/douyin/resume'
|
||||
? ({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(ctx.uni.storage.has('tb-douyin:test-api:7'), false);
|
||||
assert.equal(await ctx.prepareDouyinPay('douyin'), true);
|
||||
}
|
||||
});
|
||||
|
||||
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')});
|
||||
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});};
|
||||
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 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'}); }});
|
||||
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});
|
||||
await first;
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('resume refuses mismatched order, bad signed parameters and native initialization failure', async () => {
|
||||
for (const mode of ['outer-order','inner-order','signature','init']) {
|
||||
const ctx = await payContext({openDypay:()=>assert.fail('Invalid resume reached SDK')});
|
||||
ctx.uni.storage.set('tb-douyin:test-api:7', payment.orderNum);
|
||||
const resumePayment = JSON.parse(JSON.stringify(payment));
|
||||
if (mode === 'inner-order') resumePayment.orderNum = 'different';
|
||||
if (mode === 'signature') delete resumePayment.douyin.sign;
|
||||
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');
|
||||
assert.equal(ctx.uni.storage.get('tb-douyin:test-api:7'), payment.orderNum);
|
||||
}
|
||||
});
|
||||
|
||||
test('native initialization failure before first payment does not leave a permanent lock', async () => {
|
||||
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);
|
||||
});
|
||||
|
||||
+56
-15
@@ -52,7 +52,57 @@ function showState(state, key, orderNum) {
|
||||
? '支付未成功,请查看订单' : '支付结果待确认,请稍后查看订单', icon: 'none', duration: 2500 });
|
||||
}
|
||||
|
||||
// Runs BEFORE creating an order. Never start another payment while an earlier result is unknown.
|
||||
// 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 });
|
||||
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' };
|
||||
showState(state, key, orderNum);
|
||||
return state;
|
||||
}
|
||||
if (result.state !== 'unpaid') {
|
||||
const state = { state: 'pending' };
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
async function executePayment(payment, key) {
|
||||
// #ifdef APP-PLUS
|
||||
if (key !== accountKey()) return { state: 'pending' };
|
||||
const payInfo = toSdkPayInfo(payment);
|
||||
if (!canOpenDypay()) throw new Error('请先安装或升级抖音客户端');
|
||||
if (!initDypay({ appId: payInfo.appid, callbackScheme: payment.douyin.callbackScheme || 'trustbridgeapp' })) {
|
||||
throw new Error('支付初始化失败,请稍后重试');
|
||||
}
|
||||
// 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 state = await query(payment.orderNum);
|
||||
showState(state, key, payment.orderNum);
|
||||
return state;
|
||||
// #endif
|
||||
}
|
||||
|
||||
// Runs BEFORE creating an order. A pending record offers explicit continuation, not a new order.
|
||||
export async function prepareDouyinPay(payway) {
|
||||
if (payway !== 'douyin') return true;
|
||||
try {
|
||||
@@ -66,7 +116,7 @@ export async function prepareDouyinPay(payway) {
|
||||
const previous = uni.getStorageSync(key);
|
||||
if (previous) {
|
||||
resuming = true;
|
||||
try { showState(await query(previous), key, previous); }
|
||||
try { await continuePendingPayment(key, previous); }
|
||||
finally { resuming = false; }
|
||||
return false;
|
||||
}
|
||||
@@ -86,26 +136,17 @@ export async function appDypayFun(payment) {
|
||||
return;
|
||||
// #endif
|
||||
// #ifdef APP-PLUS
|
||||
if (active) return;
|
||||
if (active || resuming) return;
|
||||
active = true;
|
||||
const key = accountKey();
|
||||
try {
|
||||
if (!key || !payment.orderNum) throw new Error('登录身份或支付订单编号缺失');
|
||||
const payInfo = toSdkPayInfo(payment);
|
||||
const previous = uni.getStorageSync(key);
|
||||
if (previous) {
|
||||
const state = await query(previous);
|
||||
showState(state, key, previous);
|
||||
if (state.state === 'pending' || previous === payment.orderNum) return state;
|
||||
// Even legacy callers must not open a different payment while the saved one is unresolved.
|
||||
return await continuePendingPayment(key, previous);
|
||||
}
|
||||
uni.setStorageSync(key, payment.orderNum);
|
||||
if (!initDypay({ appId: payInfo.appid, callbackScheme: payment.douyin.callbackScheme || 'trustbridgeapp' })) {
|
||||
throw new Error('支付初始化失败,请先查询订单');
|
||||
}
|
||||
await new Promise(resolve => openDypay({ payInfo, showLoading: true }, resolve));
|
||||
const state = await query(payment.orderNum);
|
||||
showState(state, key, payment.orderNum);
|
||||
return state;
|
||||
return await executePayment(payment, key);
|
||||
} catch (error) {
|
||||
if (key === accountKey()) uni.showToast({ title: error.message || '支付结果待确认,请查看订单', icon: 'none' });
|
||||
return { state: 'error', message: error.message || '支付结果待确认,请查看订单' };
|
||||
|
||||
Reference in New Issue
Block a user