feat(voucher): 兑换券业务严格隔离、前台全面去¥化、不足强拦截与售后闭环返还加固

1. 普通商品购买流程中严格禁止兑换券抵扣,仅限积分商城支持同等兑换
2. 积分商城卡片、详情、规格及结算页全面去除「¥」符号,强化「个兑换券 / 券」纯资产心智
3. 兑换券与积分逻辑完全对齐,兑换券不足时禁止掏现金补差,前端置灰禁用+后端抛出「兑换券不足」异常
4. 修复售后退款状态机识别偏差,并在订单失效/取消中增加已核销兑换券原路返还兜底
5. 新增及更新 6 套专项自动化测试(累计 200 项断言全部通过)
这个提交包含在:
chen
2026-09-21 11:49:28 +08:00
父节点 cc7bfe1301
当前提交 b598d56483
共有 14 个文件被更改,包括 594 次插入 和 78 次删除

查看文件

@@ -36,7 +36,7 @@
<text class="capsule-dot"></text>
<text class="capsule-text">365天按日线性释放 · 每日自动到账可抵扣</text>
</view>
<text class="capsule-tag">全场通用</text>
<text class="capsule-tag">积分兑换专享</text>
</view>
<!-- 底部数据栏:累计获得 / 已释放 / 已使用 -->

查看文件

@@ -29,18 +29,21 @@
<!-- 价格+标题 -->
<view class="title-card detail-card ss-p-y-40 ss-p-x-20">
<view class="ss-flex ss-row-between ss-col-center ss-m-b-18">
<view class="price-box ss-flex ss-col-bottom">
<view v-if="goodsPrice.price > 0" class="price-text"> ¥{{ goodsPrice.price }} </view>
<text v-if="goodsPrice.price > 0 && goodsPrice.score > 0">+</text>
<image
v-if="goodsPrice.score > 0"
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
class="score-img"
></image>
<view v-if="goodsPrice.score > 0" class="score-text ss-m-r-16">
{{ goodsPrice.score }}
<view class="price-box ss-flex ss-col-bottom ss-flex-wrap">
<view v-if="Number(goodsPrice.price) > 0" class="ss-flex ss-col-bottom">
<text class="price-text">{{ formatVoucher(goodsPrice.price) }}</text>
<text class="unit-text ss-m-l-6 ss-m-r-8">个兑换券</text>
</view>
<view v-if="goodsPrice.price > 0" class="voucher-tip-tag">支持兑换券全额抵扣</view>
<view v-if="Number(goodsPrice.price) > 0 && Number(goodsPrice.score) > 0" class="plus-text ss-m-r-8">+</view>
<view v-if="Number(goodsPrice.score) > 0" class="ss-flex ss-col-center">
<image
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
class="score-img"
></image>
<text class="score-text ss-m-r-8">{{ goodsPrice.score }}</text>
<text class="unit-text ss-m-r-16">积分</text>
</view>
<view class="voucher-tip-tag">兑换专享</view>
</view>
<view class="sales-text">
{{ formatExchange(state.goodsInfo.sales_show_type, state.goodsInfo.sales) }}
@@ -106,6 +109,13 @@
import sheep from '@/sheep';
import { isEmpty } from 'lodash';
import { formatExchange, formatGoodsSwiper } from '@/sheep/hooks/useGoods';
function formatVoucher(val) {
if (val === '' || val === null || val === undefined || val === '--') return '0';
const num = Number(val);
if (isNaN(num)) return '0';
return num % 1 === 0 ? String(Math.round(num)) : String(Number(num.toFixed(2)));
}
import detailNavbar from './components/detail/detail-navbar.vue';
import detailCellSku from './components/detail/detail-cell-sku.vue';
import detailCellService from './components/detail/detail-cell-service.vue';
@@ -239,6 +249,18 @@
line-height: 36rpx;
font-family: OPPOSANS;
}
.unit-text {
font-size: 24rpx;
color: #ff3000;
font-weight: 500;
line-height: 36rpx;
}
.plus-text {
font-size: 32rpx;
color: #ff3000;
font-weight: 500;
line-height: 36rpx;
}
.voucher-tip-tag {
font-size: 20rpx;
color: #ff3000;

查看文件

@@ -47,9 +47,10 @@
<view class="bg-white total-card-box ss-p-20 ss-m-b-14 ss-r-20">
<view class="total-box-content border-bottom">
<view class="order-item ss-flex ss-col-center ss-row-between">
<view class="item-title">商品金额</view>
<view class="item-title">{{ state.orderPayload.order_type === 'score' ? '所需兑换券' : '商品金额' }}</view>
<view class="ss-flex ss-col-center">
<text class="item-value ss-m-r-24">¥{{ state.orderInfo.goods_amount }}</text>
<text class="item-value ss-m-r-24" v-if="state.orderPayload.order_type === 'score'">{{ formatVoucher(state.orderInfo.goods_amount) }} 个</text>
<text class="item-value ss-m-r-24" v-else>¥{{ state.orderInfo.goods_amount }}</text>
</view>
</view>
<view
@@ -97,6 +98,7 @@
</view>
<view
class="order-item ss-flex ss-col-center ss-row-between"
v-if="state.orderPayload.order_type === 'score'"
>
<view class="item-title">兑换券</view>
<view class="ss-flex ss-col-center" @tap="openVoucher">
@@ -131,15 +133,28 @@
<view class="total-box-footer ss-font-28 ss-flex ss-row-right ss-col-center ss-m-r-28">
<view class="total-num ss-m-r-20">共{{ state.totalNumber }}件</view>
<view>合计:</view>
<view class="total-num text-red"> ¥{{ state.orderInfo.pay_fee }} </view>
<view class="ss-flex" v-if="state.orderPayload.order_type === 'score'">
<view class="total-num ss-font-30 text-red ss-m-l-4"> + </view>
<image
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
class="score-img"
></image>
<view class="total-num ss-font-30 text-red">{{ state.orderInfo.score_amount }}</view>
</view>
<template v-if="state.orderPayload.order_type === 'score'">
<view class="total-num text-red ss-flex ss-col-center">
<text v-if="Number(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) > 0">
{{ formatVoucher(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) }} 个兑换券
</text>
<text v-if="Number(state.orderInfo.pay_fee) > 0">
<text v-if="Number(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) > 0"> + </text>
¥{{ state.orderInfo.pay_fee }}
</text>
<view class="ss-flex" v-if="Number(state.orderInfo.score_amount) > 0">
<text class="ss-m-x-4"> + </text>
<image
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
class="score-img"
></image>
<text class="total-num ss-font-30 text-red">{{ state.orderInfo.score_amount }} 积分</text>
</view>
</view>
</template>
<template v-else>
<view class="total-num text-red"> ¥{{ state.orderInfo.pay_fee }} </view>
</template>
</view>
</view>
<!-- 发票 -->
@@ -160,7 +175,7 @@
@close="state.showCoupon = false"
/>
<su-popup
:show="state.showVoucher"
:show="state.showVoucher && state.orderPayload.order_type === 'score'"
type="bottom"
round="20"
showClose
@@ -196,22 +211,37 @@
<su-fixed bottom :opacity="false" bg="bg-white" placeholder :noFixed="false" :index="200">
<view class="footer-box border-top ss-flex ss-row-between ss-p-x-20 ss-col-center">
<view class="total-box-footer ss-flex ss-col-center">
<view class="total-num ss-font-30 text-red"> ¥{{ state.orderInfo.pay_fee }} </view>
<view v-if="state.orderPayload.order_type === 'score' && Number(state.orderInfo.score_amount) > 0" class="ss-flex">
<view class="total-num ss-font-30 text-red ss-m-l-4">+</view>
<image
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
class="score-img"
></image>
<view class="total-num ss-font-30 text-red">{{ state.orderInfo.score_amount }}</view>
</view>
<template v-if="state.orderPayload.order_type === 'score'">
<view class="total-num ss-font-30 text-red ss-flex ss-col-center">
<text v-if="Number(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) > 0">
{{ formatVoucher(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) }} 个兑换券
</text>
<text v-if="Number(state.orderInfo.pay_fee) > 0">
<text v-if="Number(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) > 0"> + </text>
¥{{ state.orderInfo.pay_fee }}
</text>
<view v-if="Number(state.orderInfo.score_amount) > 0" class="ss-flex ss-col-center">
<text class="ss-m-x-4">+</text>
<image
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
class="score-img"
></image>
<text class="total-num ss-font-30 text-red">{{ state.orderInfo.score_amount }} 积分</text>
</view>
</view>
</template>
<template v-else>
<view class="total-num ss-font-30 text-red"> ¥{{ state.orderInfo.pay_fee }} </view>
</template>
</view>
<button
class="ss-reset-button ui-BG-Main-Gradient ss-r-40 submit-btn ui-Shadow-Main"
:class="{ 'disabled-btn': isVoucherNotEnough }"
:disabled="isVoucherNotEnough"
@tap="onConfirm"
>
{{ exchangeNow ? '立即兑换' : '提交订单' }}
{{ isVoucherNotEnough ? '兑换券不足' : (exchangeNow ? '立即兑换' : '提交订单') }}
</button>
</view>
</su-fixed>
@@ -242,6 +272,14 @@
() => state.orderPayload.order_type === 'score' && state.orderInfo.pay_fee == 0,
);
// 兑换券是否不足(与积分逻辑一致:所需兑换券大于可用兑换券时禁止兑换)
const isVoucherNotEnough = computed(() => {
if (state.orderPayload.order_type !== 'score') return false;
const needVoucher = Number(state.orderInfo.goods_amount || 0);
const availableVoucher = Number(state.orderInfo.voucher_available || 0);
return needVoucher > 0 && availableVoucher < needVoucher;
});
// 选择地址
function onSelectAddress() {
uni.$once('SELECT_ADDRESS', (e) => {
@@ -280,6 +318,9 @@
}
function openVoucher() {
if (state.orderPayload.order_type !== 'score') {
return;
}
if (!(Number(state.orderInfo.voucher_available) > 0) && !(Number(state.orderPayload.voucher_fee) > 0)) {
sheep.$helper.toast('暂无可用兑换券');
return;
@@ -314,6 +355,10 @@
// 提交订单/立即兑换
function onConfirm() {
if (state.orderPayload.order_type === 'score' && isVoucherNotEnough.value) {
sheep.$helper.toast('可用兑换券不足,无法兑换');
return;
}
if (!state.orderPayload.address_id && state.orderInfo.need_address === 1) {
sheep.$helper.toast('请选择收货地址');
return;
@@ -350,6 +395,13 @@
// 创建订单&跳转
async function submitOrder() {
if (state.orderPayload.order_type === 'score' && isVoucherNotEnough.value) {
sheep.$helper.toast('可用兑换券不足,无法兑换');
return;
}
if (state.orderPayload.order_type !== 'score') {
delete state.orderPayload.voucher_fee;
}
const { code, data } = await sheep.$api.order.create(state.orderPayload);
if (code === 1) {
// 更新购物车列表
@@ -382,6 +434,8 @@
// 积分/兑换商城订单:若后端自动计算了兑换券抵扣,同步至 orderPayload
if (state.orderPayload.order_type === 'score' && state.orderPayload.voucher_fee === undefined && data.voucher_fee !== undefined) {
state.orderPayload.voucher_fee = data.voucher_fee;
} else if (state.orderPayload.order_type !== 'score') {
delete state.orderPayload.voucher_fee;
}
}
@@ -486,6 +540,12 @@
font-size: 28rpx;
font-weight: 500;
&.disabled-btn {
background: #ccc !important;
color: #fff !important;
box-shadow: none !important;
}
.goto-pay-text {
line-height: 28rpx;
}

查看文件

@@ -29,12 +29,13 @@
<view class="ss-col-bottom">
<view
v-if="goodsFields.score_price?.show"
class="md-goods-price ss-m-t-16 font-OPPOSANS ss-m-r-10 ss-flex ss-col-center"
class="md-goods-price ss-m-t-16 font-OPPOSANS ss-m-r-10 ss-flex ss-col-center ss-flex-wrap"
:style="[{ color: goodsFields.score_price.color }]"
>
<view v-if="Number(data.price?.[0] || data.price) > 0">
¥{{ data.price?.[0] || data.price }}
<text v-if="Number(data.score) > 0">+</text>
<view v-if="Number(data.price?.[0] || data.price) > 0" class="ss-flex ss-col-center">
<text>{{ formatVoucher(data.price?.[0] || data.price) }}</text>
<text class="ss-font-20 ss-m-l-2">券</text>
<text v-if="Number(data.score) > 0" class="ss-m-x-4">+</text>
</view>
<image
v-if="Number(data.score) > 0"
@@ -42,9 +43,6 @@
class="score-img"
></image>
<text v-if="Number(data.score) > 0">{{ data.score }}</text>
<view v-if="Number(data.score) <= 0 && Number(data.price?.[0] || data.price) > 0" class="voucher-sub-tag ss-m-l-8">
兑换券
</view>
</view>
<view
@@ -95,12 +93,13 @@
<view class="ss-m-t-10">
<view
v-if="goodsFields.score_price?.show"
class="lg-goods-price ss-m-r-12 ss-flex ss-col-center font-OPPOSANS"
class="lg-goods-price ss-m-r-12 ss-flex ss-col-center ss-flex-wrap font-OPPOSANS"
:style="[{ color: goodsFields.score_price.color }]"
>
<view v-if="Number(data.price?.[0] || data.price) > 0">
¥{{ data.price?.[0] || data.price }}
<text v-if="Number(data.score) > 0">+</text>
<view v-if="Number(data.price?.[0] || data.price) > 0" class="ss-flex ss-col-center">
<text>{{ formatVoucher(data.price?.[0] || data.price) }}</text>
<text class="ss-font-20 ss-m-l-2">券</text>
<text v-if="Number(data.score) > 0" class="ss-m-x-4">+</text>
</view>
<image
v-if="Number(data.score) > 0"
@@ -108,9 +107,6 @@
class="score-img"
></image>
<text v-if="Number(data.score) > 0">{{ data.score }}</text>
<view v-if="Number(data.score) <= 0 && Number(data.price?.[0] || data.price) > 0" class="voucher-sub-tag ss-m-l-8">
兑换券
</view>
</view>
<view
v-if="goodsFields.price?.show && data.original_price > 0"
@@ -160,20 +156,18 @@
</view>
</slot>
<view class="ss-flex ss-col-bottom ss-p-l-16 ss-p-r-16 font-OPPOSANS">
<view class="sl-goods-price ss-m-r-12 ss-flex ss-col-center">
<view v-if="Number(data.price?.[0] || data.price) > 0">
¥{{ data.price?.[0] || data.price }}
<text v-if="Number(data.score) > 0">+</text>
<view class="sl-goods-price ss-m-r-12 ss-flex ss-col-center ss-flex-wrap">
<view v-if="Number(data.price?.[0] || data.price) > 0" class="ss-flex ss-col-center">
<text>{{ formatVoucher(data.price?.[0] || data.price) }}</text>
<text class="ss-font-20 ss-m-l-2">券</text>
<text v-if="Number(data.score) > 0" class="ss-m-x-4">+</text>
</view>
<image
v-if="Number(data.score) > 0"
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')"
class="score-img"
></image>
<view v-if="Number(data.score) > 0">{{ data.score }}</view>
<view v-if="Number(data.score) <= 0 && Number(data.price?.[0] || data.price) > 0" class="voucher-sub-tag ss-m-l-8">
兑换券
</view>
<text v-if="Number(data.score) > 0">{{ data.score }}</text>
</view>
<view
v-if="data.original_price > 0"
@@ -201,6 +195,13 @@
import { formatSales } from '@/sheep/hooks/useGoods';
import { formatStock } from '@/sheep/hooks/useGoods';
import goodsCollectVue from '@/pages/user/goods-collect.vue';
function formatVoucher(val) {
if (val === '' || val === null || val === undefined || val === '--') return '0';
const num = Number(val);
if (isNaN(num)) return '0';
return num % 1 === 0 ? String(Math.round(num)) : String(Number(num.toFixed(2)));
}
/**
* 订单卡片
*

查看文件

@@ -10,17 +10,22 @@
<view class="header-right ss-flex-col ss-row-between ss-flex-1">
<view class="goods-title ss-line-2">{{ goodsInfo.title }}</view>
<view class="header-right-bottom ss-flex ss-col-center ss-row-between">
<view class="ss-flex">
<format-price :price="displaygoods.price" type="range" color="#FF4104" :decimal-scale="1" />
<view class="ss-flex">
<view v-if="displaygoods.score > 0" class="score-text ss-m-l-4">+</view>
<image v-if="displaygoods.score > 0"
:src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')" class="score-img"></image>
<view v-if="displaygoods.score > 0" class="score-text">
{{ displaygoods.score }}
<view class="ss-flex ss-col-center">
<template v-if="isScore">
<view v-if="Number(displaygoods.price) > 0" class="ss-flex ss-col-center">
<text class="score-text" style="color: #FF4104; font-weight: 600; font-size: 34rpx;">{{ formatVoucher(displaygoods.price) }}</text>
<text class="ss-font-22 ss-m-l-4" style="color: #FF4104;">个兑换券</text>
</view>
</view>
<view v-if="Number(displaygoods.price) > 0 && Number(displaygoods.score) > 0" class="score-text ss-m-x-6" style="color: #FF4104;">+</view>
<view v-if="Number(displaygoods.score) > 0" class="ss-flex ss-col-center">
<image :src="sheep.$url.static('/assets/addons/shopro/uniapp/goods/score1.svg')" class="score-img"></image>
<view class="score-text" style="color: #FF4104; font-weight: 600; font-size: 34rpx;">{{ displaygoods.score }}</view>
<text class="ss-font-22 ss-m-l-4" style="color: #FF4104;">积分</text>
</view>
</template>
<template v-else>
<format-price :price="displaygoods.price" type="range" color="#FF4104" :decimal-scale="1" />
</template>
</view>
<view class="stock-text ss-m-l-20">
@@ -80,6 +85,13 @@ import { formatStock } from '@/sheep/hooks/useGoods';
import { isArray, isEmpty } from 'lodash';
import FormatPrice from '@/pages/goods/components/format-price.vue';
function formatVoucher(val) {
if (val === '' || val === null || val === undefined || val === '--') return '0';
const num = Number(val);
if (isNaN(num)) return '0';
return num % 1 === 0 ? String(Math.round(num)) : String(Number(num.toFixed(2)));
}
const emits = defineEmits(['change', 'addCart', 'buyNow', 'close']);
const props = defineProps({
goods: {