feat(utils, payment): add execution lock utils and prevent duplicate payment requests
Add two new throttle utility methods: createAsyncThrottle and createExecutionLock for safe async operation locking. Update the existing throttle method's comment to clarify traditional timestamp usage. Replace DebounceUtils usage with ThrottleUtils.createExecutionLock in order footer and booking page payment handlers to prevent duplicate payments. Rewrite uni.requestPayment calls to use Promise wrapping for proper async handling, and add try/catch/finally blocks to ensure loading state is always cleaned up correctly. Fix template formatting and trailing commas across modified files.
This commit is contained in:
@@ -1,95 +1,56 @@
|
||||
<template>
|
||||
<view class="booking h-screen flex flex-col">
|
||||
<TopNavBar
|
||||
titleAlign="center"
|
||||
:backgroundColor="$theme-color-100"
|
||||
backIconColor="#000"
|
||||
:shadow="false"
|
||||
>
|
||||
<TopNavBar titleAlign="center" :backgroundColor="$theme - color - 100" backIconColor="#000" :shadow="false">
|
||||
<template #title>
|
||||
{{ GOODS_TYPE[orderData.orderType] }}
|
||||
</template>
|
||||
</TopNavBar>
|
||||
|
||||
<view
|
||||
class="booking-content flex-full border-box p-12 overflow-hidden scroll-y"
|
||||
>
|
||||
<view class="booking-content flex-full border-box p-12 overflow-hidden scroll-y">
|
||||
<!-- 预约内容 -->
|
||||
<view class="border-box bg-white p-12 rounded-12 mb-12">
|
||||
<!-- 酒店类型入住离店日期部分 -->
|
||||
<DateRangeSection
|
||||
v-if="orderData.orderType == 0"
|
||||
:selectedDate="selectedDate"
|
||||
:showBtn="true"
|
||||
@click="navigateToDetail(orderData)"
|
||||
/>
|
||||
<DateRangeSection v-if="orderData.orderType == 0" :selectedDate="selectedDate" :showBtn="true"
|
||||
@click="navigateToDetail(orderData)" />
|
||||
|
||||
<view class="font-size-16 font-500 color-000 line-height-24 ellipsis-1">
|
||||
{{ orderData.commodityName }}
|
||||
</view>
|
||||
|
||||
<view class="border-box border-bottom">
|
||||
<view
|
||||
class="font-size-12 color-99A0AE line-height-16 pb-12 break-all"
|
||||
>
|
||||
<view class="font-size-12 color-99A0AE line-height-16 pb-12 break-all">
|
||||
{{ orderData.commodityDescription }}
|
||||
</view>
|
||||
|
||||
<!-- 权益部分 -->
|
||||
<view class="flex flex-items-center mb-8">
|
||||
<text
|
||||
class="bg-F7F7F7 border-box rounded-4 font-size-11 color-525866 mr-4 pt-4 pb-4 pl-6 pr-6"
|
||||
v-for="(item, index) in orderData.commodityFacilityList"
|
||||
:key="index"
|
||||
>
|
||||
<text class="bg-F7F7F7 border-box rounded-4 font-size-11 color-525866 mr-4 pt-4 pb-4 pl-6 pr-6"
|
||||
v-for="(item, index) in orderData.commodityFacilityList" :key="index">
|
||||
{{ item }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="border-box flex flex-items-center flex-justify-between pt-12"
|
||||
>
|
||||
<text class="font-size-12 color-525866 line-height-18"
|
||||
>取消政策及说明</text
|
||||
>
|
||||
<view class="border-box flex flex-items-center flex-justify-between pt-12">
|
||||
<text class="font-size-12 color-525866 line-height-18">取消政策及说明</text>
|
||||
<view class="flex flex-items-center">
|
||||
<text
|
||||
class="font-size-12 theme-color-500 line-height-16"
|
||||
@click="refundVisible = true"
|
||||
>取消政策</text
|
||||
>
|
||||
<text class="font-size-12 theme-color-500 line-height-16" @click="refundVisible = true">取消政策</text>
|
||||
<uni-icons type="right" size="15" color="#99A0AE" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 非酒店类型 -->
|
||||
<ContactSection
|
||||
v-if="orderData.orderType != 0"
|
||||
v-model="quantity"
|
||||
:userFormList="userFormList"
|
||||
v-model:reservationDate="selectedReservationDate"
|
||||
:orderData="orderData"
|
||||
/>
|
||||
<ContactSection v-if="orderData.orderType != 0" v-model="quantity" :userFormList="userFormList"
|
||||
v-model:reservationDate="selectedReservationDate" :orderData="orderData" />
|
||||
|
||||
<!-- 酒店类型 -->
|
||||
<UserSection
|
||||
v-if="orderData.orderType == 0"
|
||||
v-model="quantity"
|
||||
:userFormList="userFormList"
|
||||
/>
|
||||
<UserSection v-if="orderData.orderType == 0" v-model="quantity" :userFormList="userFormList" />
|
||||
</view>
|
||||
|
||||
<!-- 底部 -->
|
||||
<FooterSection
|
||||
v-if="Object.keys(orderData).length"
|
||||
v-model="quantity"
|
||||
:selectedDate="selectedDate"
|
||||
:orderData="orderData"
|
||||
@detailClick="detailVisible = true"
|
||||
@payClick="handlePayClick"
|
||||
/>
|
||||
<FooterSection v-if="Object.keys(orderData).length" v-model="quantity" :selectedDate="selectedDate"
|
||||
:orderData="orderData" @detailClick="detailVisible = true" @payClick="handlePayClick" />
|
||||
|
||||
<!-- 取消政策弹窗 -->
|
||||
<RefundPopup v-model="refundVisible" :orderData="orderData" />
|
||||
@@ -210,8 +171,8 @@ const validateUserForms = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// 处理支付点击事件
|
||||
const handlePayClick = ThrottleUtils.createThrottle(async (goodsData) => {
|
||||
// 处理支付点击事件 - 使用执行锁防止重复支付
|
||||
const handlePayClick = ThrottleUtils.createExecutionLock(async (goodsData) => {
|
||||
console.log("处理支付点击事件", userFormList.value);
|
||||
// 预约日期,酒店类型不需要
|
||||
if (orderData.value.reservationEnabled) {
|
||||
@@ -267,72 +228,82 @@ const handlePayClick = ThrottleUtils.createThrottle(async (goodsData) => {
|
||||
|
||||
// 点击后立即展示 loading
|
||||
uni.showLoading({ title: "正在提交订单..." });
|
||||
const res = await orderPay(params);
|
||||
console.log("确认订单---2:", res);
|
||||
uni.hideLoading();
|
||||
|
||||
// 检查接口返回数据
|
||||
if (!res || !res.data) {
|
||||
try {
|
||||
const res = await orderPay(params);
|
||||
console.log("确认订单---2:", res);
|
||||
|
||||
// 检查接口返回数据
|
||||
if (!res || !res.data) {
|
||||
setTimeout(() => {
|
||||
uni.showToast({
|
||||
title: res.msg || "订单创建失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = res;
|
||||
const { nonceStr, packageVal, paySign, signType, timeStamp } = data;
|
||||
|
||||
// 验证支付参数是否完整
|
||||
if (!nonceStr || !packageVal || !paySign || !signType || !timeStamp) {
|
||||
setTimeout(() => {
|
||||
uni.showToast({ title: "支付参数错误,请重试", icon: "none" });
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// 关闭 loading
|
||||
uni.hideLoading();
|
||||
setTimeout(() => {
|
||||
uni.showToast({
|
||||
title: res.msg || "订单创建失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = res;
|
||||
const { nonceStr, packageVal, paySign, signType, timeStamp } = data;
|
||||
|
||||
// 验证支付参数是否完整
|
||||
if (!nonceStr || !packageVal || !paySign || !signType || !timeStamp) {
|
||||
uni.hideLoading();
|
||||
setTimeout(() => {
|
||||
uni.showToast({ title: "支付参数错误,请重试", icon: "none" });
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// 在发起微信支付前关闭 loading(避免与原生支付 UI 冲突)
|
||||
uni.hideLoading();
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 调用微信支付
|
||||
uni.requestPayment({
|
||||
provider: "wxpay",
|
||||
timeStamp: String(timeStamp), // 确保为字符串类型
|
||||
nonceStr: String(nonceStr),
|
||||
package: String(packageVal), // 确保为字符串类型
|
||||
signType: String(signType),
|
||||
paySign: String(paySign),
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: "支付成功",
|
||||
icon: "success",
|
||||
// #ifdef MP-WEIXIN
|
||||
// 调用微信支付
|
||||
await new Promise((resolve) => {
|
||||
uni.requestPayment({
|
||||
provider: "wxpay",
|
||||
timeStamp: String(timeStamp), // 确保为字符串类型
|
||||
nonceStr: String(nonceStr),
|
||||
package: String(packageVal), // 确保为字符串类型
|
||||
signType: String(signType),
|
||||
paySign: String(paySign),
|
||||
success: () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages-order/order/list",
|
||||
uni.showToast({
|
||||
title: "支付成功",
|
||||
icon: "success",
|
||||
success: () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages-order/order/list",
|
||||
});
|
||||
},
|
||||
});
|
||||
resolve();
|
||||
},
|
||||
fail: (e) => {
|
||||
console.error("支付失败:", e);
|
||||
uni.showToast({ title: "支付失败,请重试", icon: "none" });
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
},
|
||||
fail: (e) => {
|
||||
console.error("支付失败:", e);
|
||||
uni.showToast({ title: "支付失败,请重试", icon: "none" });
|
||||
},
|
||||
});
|
||||
// #endif
|
||||
});
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
uni.showModal({
|
||||
title: "提示",
|
||||
content: "支付功能开发中",
|
||||
showCancel: false,
|
||||
});
|
||||
// #endif
|
||||
}, 1000);
|
||||
// #ifdef APP-PLUS
|
||||
uni.showModal({
|
||||
title: "提示",
|
||||
content: "支付功能开发中",
|
||||
showCancel: false,
|
||||
});
|
||||
// #endif
|
||||
} catch (error) {
|
||||
console.error("支付流程出错:", error);
|
||||
uni.showToast({ title: "支付失败,请重试", icon: "none" });
|
||||
} finally {
|
||||
// 确保 loading 被关闭
|
||||
uni.hideLoading();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
<template>
|
||||
<view
|
||||
class="footer bg-white border-box flex flex-items-center flex-justify-between p-12"
|
||||
>
|
||||
<button
|
||||
v-if="['1', '2'].includes(statusCode)"
|
||||
<view class="footer bg-white border-box flex flex-items-center flex-justify-between p-12">
|
||||
<button v-if="['1', '2'].includes(statusCode)"
|
||||
class="left border-none border-box bg-white rounded-10 flex flex-items-center flex-justify-center font-size-14 font-500 color-525866 mr-12"
|
||||
@click="emit('refund', orderData)"
|
||||
>
|
||||
@click="emit('refund', orderData)">
|
||||
申请退款
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'right border-none rounded-10 flex flex-full flex-items-center flex-justify-center font-size-14 font-500 bg-theme-color-500',
|
||||
{
|
||||
'bg-FF3D60': statusCode === '0',
|
||||
'color-white': ['1', '2', '3', '4', '5', '6'].includes(statusCode),
|
||||
},
|
||||
]"
|
||||
@click="handleButtonClick(orderData)"
|
||||
>
|
||||
<button :class="[
|
||||
'right border-none rounded-10 flex flex-full flex-items-center flex-justify-center font-size-14 font-500 bg-theme-color-500',
|
||||
{
|
||||
'bg-FF3D60': statusCode === '0',
|
||||
'color-white': ['1', '2', '3', '4', '5', '6'].includes(statusCode),
|
||||
},
|
||||
]" @click="handleButtonClick(orderData)">
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
</view>
|
||||
@@ -27,7 +20,7 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, computed } from "vue";
|
||||
import { orderPayNow } from "@/request/api/OrderApi";
|
||||
import { DebounceUtils } from "@/utils";
|
||||
import { ThrottleUtils } from "@/utils";
|
||||
|
||||
const props = defineProps({
|
||||
orderData: {
|
||||
@@ -64,8 +57,8 @@ const buttonText = computed(() => {
|
||||
// 定义事件发射器
|
||||
const emit = defineEmits(["refund", "refresh"]);
|
||||
|
||||
// 处理按钮点击事件
|
||||
const handleButtonClick = DebounceUtils.createDebounce(async (orderData) => {
|
||||
// 处理按钮点击事件 - 使用执行锁防止重复支付
|
||||
const handleButtonClick = ThrottleUtils.createExecutionLock(async (orderData) => {
|
||||
try {
|
||||
// 再次预定跳转商品详情
|
||||
if (["1", "2", "3", "4", "5", "6"].includes(statusCode.value)) {
|
||||
@@ -89,7 +82,6 @@ const handleButtonClick = DebounceUtils.createDebounce(async (orderData) => {
|
||||
|
||||
// 检查接口返回数据
|
||||
if (!res || !res.data) {
|
||||
uni.hideLoading();
|
||||
setTimeout(() => {
|
||||
uni.showToast({ title: res.msg || "订单创建失败,请重试", icon: "none" });
|
||||
}, 100);
|
||||
@@ -101,38 +93,41 @@ const handleButtonClick = DebounceUtils.createDebounce(async (orderData) => {
|
||||
|
||||
// 验证支付参数是否完整
|
||||
if (!nonceStr || !packageVal || !paySign || !signType || !timeStamp) {
|
||||
uni.hideLoading();
|
||||
setTimeout(() => {
|
||||
uni.showToast({ title: "支付参数错误,请重试", icon: "none" });
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// 在发起微信支付前关闭 loading(避免与原生支付 UI 冲突)
|
||||
// 关闭 loading
|
||||
uni.hideLoading();
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 调用微信支付
|
||||
uni.requestPayment({
|
||||
provider: "wxpay",
|
||||
timeStamp: String(timeStamp), // 确保为字符串类型
|
||||
nonceStr: String(nonceStr),
|
||||
package: String(packageVal), // 确保为字符串类型
|
||||
signType: String(signType),
|
||||
paySign: String(paySign),
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: "支付成功",
|
||||
icon: "success",
|
||||
success: () => {
|
||||
console.log("支付成功,刷新订单详情");
|
||||
emit("refresh", { orderId: orderId });
|
||||
},
|
||||
});
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: "支付失败,请重试", icon: "none" });
|
||||
},
|
||||
// 调用微信支付 - 包装成 Promise 以便锁住执行状态
|
||||
await new Promise((resolve) => {
|
||||
uni.requestPayment({
|
||||
provider: "wxpay",
|
||||
timeStamp: String(timeStamp), // 确保为字符串类型
|
||||
nonceStr: String(nonceStr),
|
||||
package: String(packageVal), // 确保为字符串类型
|
||||
signType: String(signType),
|
||||
paySign: String(paySign),
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: "支付成功",
|
||||
icon: "success",
|
||||
success: () => {
|
||||
console.log("支付成功,刷新订单详情");
|
||||
emit("refresh", { orderId: orderId });
|
||||
},
|
||||
});
|
||||
resolve();
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: "支付失败,请重试", icon: "none" });
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
// #endif
|
||||
|
||||
@@ -146,9 +141,12 @@ const handleButtonClick = DebounceUtils.createDebounce(async (orderData) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("操作失败:", error);
|
||||
uni.showToast({ title: "操作失败,请重试", icon: "none" });
|
||||
} finally {
|
||||
// 确保 loading 被关闭
|
||||
uni.hideLoading();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -16,7 +16,7 @@ export class IdUtils {
|
||||
const timestamp = new Date().getTime();
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz";
|
||||
const randomStr = Array.from({ length: 4 }, () =>
|
||||
chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
chars.charAt(Math.floor(Math.random() * chars.length)),
|
||||
).join("");
|
||||
return "mid" + randomStr + timestamp;
|
||||
}
|
||||
@@ -138,7 +138,7 @@ export class MessageUtils {
|
||||
static createTypewriterMessage(
|
||||
content,
|
||||
isComplete = false,
|
||||
type = "typewriter"
|
||||
type = "typewriter",
|
||||
) {
|
||||
return {
|
||||
type,
|
||||
@@ -280,7 +280,7 @@ export class DebounceUtils {
|
||||
*/
|
||||
export class ThrottleUtils {
|
||||
/**
|
||||
* 创建节流函数
|
||||
* 创建节流函数(传统时间戳方式)
|
||||
* @param {Function} func - 要节流的函数
|
||||
* @param {number} delay - 节流延迟时间
|
||||
* @returns {Function} 节流后的函数
|
||||
@@ -297,6 +297,81 @@ export class ThrottleUtils {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建异步安全的节流函数(带执行状态锁)
|
||||
* 适用于支付等需要等待完成的异步操作
|
||||
* @param {Function} func - 要节流的异步函数
|
||||
* @param {number} delay - 节流延迟时间(毫秒)
|
||||
* @returns {Function} 节流后的函数
|
||||
*/
|
||||
static createAsyncThrottle(func, delay = 1000) {
|
||||
let isExecuting = false; // 执行状态锁
|
||||
let lastExecTime = 0; // 上次执行时间
|
||||
|
||||
return async function (...args) {
|
||||
const now = Date.now();
|
||||
|
||||
// 如果正在执行中,直接返回
|
||||
if (isExecuting) {
|
||||
console.warn("函数正在执行中,跳过此次调用");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果还在节流时间内,直接返回
|
||||
if (now - lastExecTime < delay) {
|
||||
console.warn("节流时间内,跳过此次调用");
|
||||
return;
|
||||
}
|
||||
|
||||
// 加锁
|
||||
isExecuting = true;
|
||||
lastExecTime = now;
|
||||
|
||||
try {
|
||||
// 执行异步函数
|
||||
return await func.apply(this, args);
|
||||
} catch (error) {
|
||||
console.error("异步节流函数执行出错:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
// 无论成功失败都解锁
|
||||
isExecuting = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建执行锁函数(最简单的防重复方式,只锁执行状态)
|
||||
* 适用于支付等场景,上一次没完成前绝对不执行第二次
|
||||
* @param {Function} func - 要加锁的函数
|
||||
* @returns {Function} 加锁后的函数
|
||||
*/
|
||||
static createExecutionLock(func) {
|
||||
let isExecuting = false;
|
||||
|
||||
return async function (...args) {
|
||||
// 如果正在执行中,直接返回
|
||||
if (isExecuting) {
|
||||
console.warn("函数正在执行中,跳过此次调用");
|
||||
return;
|
||||
}
|
||||
|
||||
// 加锁
|
||||
isExecuting = true;
|
||||
|
||||
try {
|
||||
// 执行函数
|
||||
return await func.apply(this, args);
|
||||
} catch (error) {
|
||||
console.error("执行锁函数执行出错:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
// 无论成功失败都解锁
|
||||
isExecuting = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user