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

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

查看文件

@@ -575,6 +575,13 @@ class Order
'remark' => '订单失效自动退回'
]);
// 退回已核销的兑换券(若订单已核销过)
try {
(new \addons\shopro\service\partner\VoucherService())->refundConsume($order);
} catch (\Exception $e) {
\think\Log::write('voucher-refund-error:order:' . ($order->id ?? 0) . ';' . $e->getMessage());
}
if ($order->activity_id || $order->promo_types) {
// 有活动,执行活动失败
ActivityFacade::buyFail($order, 'invalid');

查看文件

@@ -65,6 +65,9 @@ class Partner
$service = new PartnerRewardService();
$orderId = intval($order['id']);
// 若订单已核销兑换券,原路返还核销额度(内部具幂等防重机制)
(new VoucherService())->refundConsume($order);
if ($this->checkIsAllRefund($orderId)) {
// 全单退款:全额回滚即时奖与兑换券计划
$service->refund($orderId);
@@ -92,7 +95,10 @@ class Partner
$unrefundedCount = \think\Db::name('shopro_order_item')
->where('order_id', $orderId)
->where('refund_status', '<>', \app\admin\model\shopro\order\OrderItem::REFUND_STATUS_COMPLETED)
->whereNotIn('refund_status', [
\app\admin\model\shopro\order\OrderItem::REFUND_STATUS_AGREE,
\app\admin\model\shopro\order\OrderItem::REFUND_STATUS_COMPLETED
])
->count();
return $unrefundedCount === 0;

查看文件

@@ -158,7 +158,7 @@ class OrderCreate
$this->invoice_id = $params['invoice_id'] ?? 0;
$this->coupon_id = $params['coupon_id'] ?? 0;
$this->voucher_fee = (isset($params['voucher_fee']) && $params['voucher_fee'] !== '') ? floatval($params['voucher_fee']) : null;
$this->voucher_fee = ($this->order_type === 'score' && isset($params['voucher_fee']) && $params['voucher_fee'] !== '') ? floatval($params['voucher_fee']) : null;
$this->remark = $params['remark'] ?? '';
$this->money = (isset($params['money']) && $params['money'] > 0) ? $params['money'] : 0;
@@ -257,8 +257,21 @@ class OrderCreate
$this->exception($this->activity['activity']['type_text'] . '必须单独购买');
}
if ($this->order_type == 'score' && count($this->goodsList) > 1) {
$this->exception('积分商品必须单独购买');
if ($this->order_type == 'score') {
if (count($this->goodsList) > 1) {
$this->exception('积分商品必须单独购买');
}
// 校验兑换券充足性(创建订单时若所需兑换券不足,严格拦截报错,杜绝现金补差)
$needVoucher = 0;
foreach ($this->goodsList as $buyInfo) {
$needVoucher += floatval($buyInfo['current_sku_price']['price'] ?? 0) * intval($buyInfo['goods_num'] ?? 1);
}
if ($needVoucher > 0 && $this->calc_type == 'create') {
$available = (new \addons\shopro\service\partner\VoucherService())->available($this->user->id);
if ($available < $needVoucher) {
$this->exception('兑换券不足');
}
}
}
// 判断是否支持货到付款
@@ -606,6 +619,13 @@ class OrderCreate
*/
public function calcVoucher()
{
// 严格业务隔离:兑换券仅在积分商城(order_type === 'score')中与积分协同兑换商品,普通商品购买一律禁止抵扣兑换券
if ($this->order_type !== 'score') {
$this->orderData['voucher_available'] = 0;
$this->orderData['voucher_fee'] = 0;
return;
}
$available = 0;
if ($this->user && $this->user->id) {
$available = (new \addons\shopro\service\partner\VoucherService())->available($this->user->id);
@@ -614,7 +634,7 @@ class OrderCreate
// 兑换商城订单:若未显式指定抵扣额度,默认自动全额应用可用兑换券
if ($this->voucher_fee === null) {
$want = $this->order_type == 'score' ? (float) $available : 0;
$want = (float) $available;
} else {
$want = max(0, round((float) $this->voucher_fee, 2));
}
@@ -1176,7 +1196,7 @@ class OrderCreate
}
$ext['offline_status'] = $result['offline_status'];
$ext['need_address'] = $result['need_address']; // 后台判断是否有 编辑收货地址按钮
$ext['voucher_fee'] = $result['voucher_fee'] ?? 0;
$ext['voucher_fee'] = ($this->order_type === 'score') ? ($result['voucher_fee'] ?? 0) : 0;
// 发票开具状态
$invoice_status = 0; // 没有申请

查看文件

@@ -0,0 +1,132 @@
/**
* 积分商城去¥化与纯兑换券心智强校验自动化测试
*
* 验证重点:
* 1. 前台视觉全面去除「¥」符号(卡片、详情页、规格弹窗、确认页小计与结算栏)
* 2. 兑换券与积分对齐:兑换券不足时禁止用现金补差变相购买
* 3. 前端按钮置灰拦截 + 后端 OrderCreate calcCheck 异常抛出双重保障
*/
const fs = require('fs');
const path = require('path');
let passCount = 0;
let failCount = 0;
function assert(condition, message) {
if (condition) {
console.log(` \x1b[32m[PASS]\x1b[0m ${message}`);
passCount++;
} else {
console.error(` \x1b[31m[FAIL]\x1b[0m ${message}`);
failCount++;
}
}
console.log('========================================================');
console.log(' 积分商城去¥化与纯兑换券强拦截自动化验证');
console.log('========================================================\n');
// 1. s-score-card.vue 去¥化验证
const sScoreCardPath = path.resolve(__dirname, '../uniapp/sheep/components/s-score-card/s-score-card.vue');
assert(fs.existsSync(sScoreCardPath), 's-score-card.vue 文件存在');
if (fs.existsSync(sScoreCardPath)) {
const content = fs.readFileSync(sScoreCardPath, 'utf-8');
assert(content.includes('formatVoucher'), 's-score-card.vue 引入 formatVoucher 辅助格式化');
assert(!content.includes("¥{{"), 's-score-card.vue 彻底移除模板中的 ¥ 符号前缀');
assert(content.includes('formatVoucher(data.price?.[0] || data.price)'), 's-score-card.vue 兑换券金额采用 formatVoucher 格式化');
assert(content.includes('券'), 's-score-card.vue 正确展示「券」单位');
}
// 2. score.vue 详情页去¥化验证
const scoreVuePath = path.resolve(__dirname, '../uniapp/pages/goods/score.vue');
assert(fs.existsSync(scoreVuePath), 'score.vue 详情页文件存在');
if (fs.existsSync(scoreVuePath)) {
const content = fs.readFileSync(scoreVuePath, 'utf-8');
assert(!content.includes("¥{{ goodsPrice.price }}"), 'score.vue 详情页价格栏移除 ¥ 前缀');
assert(content.includes('{{ formatVoucher(goodsPrice.price) }}'), 'score.vue 详情页使用 formatVoucher 格式化兑换券');
assert(content.includes('个兑换券'), 'score.vue 标明「个兑换券」单位');
assert(content.includes('兑换专享'), 'score.vue 具有「兑换专享」专属标识');
}
// 3. s-select-sku.vue 规格弹窗去¥化验证
const selectSkuPath = path.resolve(__dirname, '../uniapp/sheep/components/s-select-sku/s-select-sku.vue');
assert(fs.existsSync(selectSkuPath), 's-select-sku.vue 规格弹层文件存在');
if (fs.existsSync(selectSkuPath)) {
const content = fs.readFileSync(selectSkuPath, 'utf-8');
assert(content.includes('formatVoucher(displaygoods.price)'), 's-select-sku.vue 积分模式下接入 formatVoucher');
assert(content.includes('isScore'), 's-select-sku.vue 包含 isScore 模式分支判断');
assert(content.includes('个兑换券'), 's-select-sku.vue 弹层明确展示「个兑换券」');
}
// 4. confirm.vue 确认页去¥化与不足拦截验证
const confirmVuePath = path.resolve(__dirname, '../uniapp/pages/order/confirm.vue');
assert(fs.existsSync(confirmVuePath), 'confirm.vue 确认订单页文件存在');
if (fs.existsSync(confirmVuePath)) {
const content = fs.readFileSync(confirmVuePath, 'utf-8');
// 去¥化
assert(content.includes("state.orderPayload.order_type === 'score' ? '所需兑换券' : '商品金额'"), 'confirm.vue 费用明细展示「所需兑换券」');
assert(content.includes('formatVoucher(state.orderInfo.voucher_fee || state.orderPayload.voucher_fee) }} 个兑换券'), 'confirm.vue 小计与吸底栏展示「XX 个兑换券」');
// 不足拦截逻辑
assert(content.includes('isVoucherNotEnough'), 'confirm.vue 包含 isVoucherNotEnough 计算属性');
assert(content.includes(':disabled="isVoucherNotEnough"'), 'confirm.vue 兑换券不足时禁用提交按钮');
assert(content.includes("isVoucherNotEnough ? '兑换券不足' : (exchangeNow ? '立即兑换' : '提交订单')"), 'confirm.vue 按钮文案在不足时动态呈现「兑换券不足」');
assert(content.includes("sheep.$helper.toast('可用兑换券不足,无法兑换')"), 'confirm.vue 提交前置弹窗与提交方法具备双重 toast 拦截');
}
// 5. OrderCreate.php 后端兑换券不足强拦截验证
const orderCreatePath = path.resolve(__dirname, '../addons/shopro/service/order/OrderCreate.php');
assert(fs.existsSync(orderCreatePath), 'OrderCreate.php 文件存在');
if (fs.existsSync(orderCreatePath)) {
const content = fs.readFileSync(orderCreatePath, 'utf-8');
const calcCheckIdx = content.indexOf('function calcCheck()');
const calcCheckBlock = content.slice(calcCheckIdx, calcCheckIdx + 3000);
assert(calcCheckBlock.includes("$this->order_type == 'score'"), 'calcCheck 包含积分订单专属检查');
assert(calcCheckBlock.includes("$this->calc_type == 'create'"), 'calcCheck 仅在下单创建阶段执行强拦截');
assert(calcCheckBlock.includes('$available < $needVoucher'), 'calcCheck 严格对比可用兑换券与所需兑换券');
assert(calcCheckBlock.includes("$this->exception('兑换券不足')"), 'calcCheck 兑换券不足时直接抛出异常拦截');
}
// 6. 核心业务逻辑模拟与边界测试
console.log('\n--- 核心业务逻辑边界断言模拟 ---');
function simulateOrderCheck(orderType, calcType, availableVoucher, needVoucher) {
if (orderType === 'score' && calcType === 'create') {
const need = Math.max(0, Number(needVoucher || 0));
const avail = Math.max(0, Number(availableVoucher || 0));
if (need > 0 && avail < need) {
return { success: false, msg: '兑换券不足' };
}
}
return { success: true, msg: '校验通过' };
}
// 场景 1:积分商品需 50 个兑换券,用户仅有 20 个兑换券,下单创建
const test1 = simulateOrderCheck('score', 'create', 20, 50);
assert(!test1.success && test1.msg === '兑换券不足', '场景 1:兑换券不足时后端拒绝创建订单,抛出「兑换券不足」');
// 场景 2:积分商品需 50 个兑换券,用户拥有 50 个兑换券,下单创建
const test2 = simulateOrderCheck('score', 'create', 50, 50);
assert(test2.success, '场景 2:兑换券足额(刚好50个)顺利通过后端校验');
// 场景 3:积分商品需 50 个兑换券,用户拥有 100 个兑换券,下单创建
const test3 = simulateOrderCheck('score', 'create', 100, 50);
assert(test3.success, '场景 3:兑换券超额(100个)顺利通过后端校验');
// 场景 4:页面试算阶段(calc_type = 'calc'),兑换券不足不阻断(便于前端返回计算结果展示)
const test4 = simulateOrderCheck('score', 'calc', 10, 50);
assert(test4.success, '场景 4:试算阶段不抛出异常,平稳返回订单结构供前端对比展示');
// 场景 5:纯积分商品(需 0 个兑换券),0 券用户兑换
const test5 = simulateOrderCheck('score', 'create', 0, 0);
assert(test5.success, '场景 5:纯积分商品(无需兑换券)不受兑换券影响正常兑换');
console.log('\n--------------------------------------------------------');
console.log(`测试执行结果: 通过 ${passCount} 项, 失败 ${failCount} 项`);
console.log('========================================================');
if (failCount > 0) {
process.exit(1);
}

查看文件

@@ -28,8 +28,7 @@ const orderCreatePath = path.resolve(__dirname, '../addons/shopro/service/order/
assert(fs.existsSync(orderCreatePath), 'OrderCreate.php 文件存在');
if (fs.existsSync(orderCreatePath)) {
const content = fs.readFileSync(orderCreatePath, 'utf-8');
assert(!content.includes("$this->order_type !== 'score'"), 'calcVoucher 移除了对 score 订单可用额度查询的封锁');
assert(!content.includes("$this->order_type === 'score' ? 0"), 'calcVoucher 移除了对 score 订单抵扣额置零的拦截');
assert(content.includes("$this->order_type !== 'score'"), 'calcVoucher 增加严格隔离:非 score 订单禁止抵扣兑换券');
assert(content.includes('$this->orderData[\'voucher_available\'] = $available'), '积分订单正常赋值 voucher_available');
assert(content.includes('$want = max(0, round((float) $this->voucher_fee, 2))'), '积分订单支持提取 voucher_fee 并参与应付现金抵扣');
}
@@ -71,7 +70,7 @@ assert(fs.existsSync(confirmVuePath), 'confirm.vue 文件存在');
if (fs.existsSync(confirmVuePath)) {
const content = fs.readFileSync(confirmVuePath, 'utf-8');
const voucherBlock = content.slice(content.indexOf('<view class="item-title">兑换券</view>') - 100, content.indexOf('<view class="item-title">兑换券</view>'));
assert(!voucherBlock.includes("order_type != 'score'"), 'confirm.vue 移除了兑换券行的 order_type != score 隐藏限制');
assert(voucherBlock.includes("order_type === 'score'"), 'confirm.vue 限制兑换券项仅在 order_type === score 时展示');
assert(content.includes('@tap="openVoucher"'), '确认订单页提供 openVoucher 交互入口');
assert(content.includes('state.orderInfo.voucher_available'), '展示可用兑换券额度');
}
@@ -102,7 +101,7 @@ assert(fs.existsSync(scoreDetailVuePath), 'score.vue 文件存在');
if (fs.existsSync(scoreDetailVuePath)) {
const content = fs.readFileSync(scoreDetailVuePath, 'utf-8');
assert(content.includes('voucher-tip-tag'), '积分商品详情包含兑换券抵扣标签组件');
assert(content.includes('支持兑换券全额抵扣'), '积分商品详情标明支持兑换券全额抵扣');
assert(content.includes('兑换专享') || content.includes('支持兑换券全额抵扣'), '积分商品详情标明兑换专享标签');
}
// 6.1 后台页面及模型验证

查看文件

@@ -80,7 +80,7 @@ if (fs.existsSync(voucherVuePath)) {
assert(content.includes('formatVoucher(state.info.voucher_used)'), 'voucher.vue 已使用统计接入 formatVoucher');
assert(content.includes('formatVoucher(item.total_amount || item.total || item.voucher_total)'), 'voucher.vue 计划总数接入 formatVoucher');
assert(content.includes('formatVoucher(item.released_amount || item.released)'), 'voucher.vue 计划已释放接入 formatVoucher');
assert(content.includes('formatVoucher(item.daily_amount)'), 'voucher.vue 计划每日释放接入 formatVoucher');
assert(content.includes('formatVoucher(item.daily_amount'), 'voucher.vue 计划每日释放接入 formatVoucher');
assert(content.includes('formatVoucher(calcRemain(item))'), 'voucher.vue 计划待释放接入 formatVoucher');
}

查看文件

@@ -0,0 +1,155 @@
/**
* 普通商品购买禁止兑换券抵扣 & 积分商城专享兑换券规范专项自动化测试
* 覆盖范围:confirm.vue, OrderCreate.php, voucher.vue, 算法防注入模拟
*/
const fs = require('fs');
const path = require('path');
let passCount = 0;
let failCount = 0;
function assert(condition, message) {
if (condition) {
console.log(` \x1b[32m[PASS]\x1b[0m ${message}`);
passCount++;
} else {
console.error(` \x1b[31m[FAIL]\x1b[0m ${message}`);
failCount++;
}
}
console.log('========================================================');
console.log(' 普通商品购买禁止兑换券抵扣 & 积分商城兑换专项验证');
console.log('========================================================\n');
// 1. UniApp 确认订单页 confirm.vue 验证
const confirmVuePath = path.resolve(__dirname, '../uniapp/pages/order/confirm.vue');
assert(fs.existsSync(confirmVuePath), 'confirm.vue 文件存在');
if (fs.existsSync(confirmVuePath)) {
const content = fs.readFileSync(confirmVuePath, 'utf-8');
// 兑换券整栏限制
const voucherIdx = content.indexOf('<view class="item-title">兑换券</view>');
assert(voucherIdx !== -1, 'confirm.vue 包含兑换券标题栏');
const voucherBlock = content.slice(voucherIdx - 120, voucherIdx);
assert(voucherBlock.includes("state.orderPayload.order_type === 'score'"), 'confirm.vue 兑换券栏严格限制仅在 order_type === score 时展示');
// 弹层 popup 限制
const popupMatch = content.includes(":show=\"state.showVoucher && state.orderPayload.order_type === 'score'\"");
assert(popupMatch, 'confirm.vue 兑换券弹窗绑定 order_type === score 显隐安全保护');
// openVoucher 拦截
assert(content.includes("if (state.orderPayload.order_type !== 'score')"), 'confirm.vue openVoucher() 包含非积分订单直接拦截');
// getOrderInfo 清理
assert(
content.includes("else if (state.orderPayload.order_type !== 'score')") &&
content.includes("delete state.orderPayload.voucher_fee"),
'confirm.vue getOrderInfo() 对非积分订单主动清理 voucher_fee'
);
// submitOrder 清理
const submitOrderIdx = content.indexOf('async function submitOrder()');
const submitOrderBody = content.slice(submitOrderIdx, submitOrderIdx + 500);
assert(
submitOrderBody.includes("state.orderPayload.order_type !== 'score'") &&
submitOrderBody.includes("delete state.orderPayload.voucher_fee"),
'confirm.vue submitOrder() 对非积分订单提交前剔除 voucher_fee'
);
}
// 2. 后端 OrderCreate.php 核心隔离验证
const orderCreatePath = path.resolve(__dirname, '../addons/shopro/service/order/OrderCreate.php');
assert(fs.existsSync(orderCreatePath), 'OrderCreate.php 文件存在');
if (fs.existsSync(orderCreatePath)) {
const content = fs.readFileSync(orderCreatePath, 'utf-8');
// calcParams 白名单解析
assert(content.includes("\$this->voucher_fee = (\$this->order_type === 'score' && isset(\$params['voucher_fee']) && \$params['voucher_fee'] !== '')"), 'calcParams 仅允许 score 积分订单解析 voucher_fee 参数');
// calcVoucher 严格前置隔离
assert(content.includes("if (\$this->order_type !== 'score') {\n \$this->orderData['voucher_available'] = 0;\n \$this->orderData['voucher_fee'] = 0;\n return;\n }"), 'calcVoucher 增加严格隔离:非 score 订单 voucher_available 与 voucher_fee 均强制为 0');
// ext 持久化防护
assert(content.includes("\$ext['voucher_fee'] = (\$this->order_type === 'score') ? (\$result['voucher_fee'] ?? 0) : 0;"), '订单 ext[\'voucher_fee\'] 仅允许积分商城订单记录,普通订单强制为 0');
}
// 3. voucher.vue 文案优化验证
const voucherVuePath = path.resolve(__dirname, '../uniapp/pages/commission/voucher.vue');
assert(fs.existsSync(voucherVuePath), 'voucher.vue 文件存在');
if (fs.existsSync(voucherVuePath)) {
const content = fs.readFileSync(voucherVuePath, 'utf-8');
assert(content.includes('积分兑换专享'), 'voucher.vue 胶囊标签展示「积分兑换专享」');
assert(!content.includes('全场通用'), 'voucher.vue 已彻底移除「全场通用」误导文案');
}
// 4. 算法与防注入场景模拟验证
console.log('\n--- 场景模拟与抵扣安全性测试 ---');
function mockOrderCalc(orderType, userVoucherAvailable, orderPayFee, inputVoucherFee = null) {
let orderData = {
voucher_available: 0,
voucher_fee: 0,
pay_fee: orderPayFee,
total_discount_fee: 0
};
// 模拟 OrderCreate::calcVoucher 隔离机制
if (orderType !== 'score') {
orderData.voucher_available = 0;
orderData.voucher_fee = 0;
return orderData;
}
let available = userVoucherAvailable;
orderData.voucher_available = available;
let want = (inputVoucherFee === null) ? available : Math.max(0, Number(inputVoucherFee));
let apply = Math.min(want, available, orderData.pay_fee);
apply = apply > 0 ? apply : 0;
orderData.voucher_fee = apply;
if (apply > 0) {
orderData.pay_fee = Math.round((orderData.pay_fee - apply) * 100) / 100;
orderData.total_discount_fee = Math.round((orderData.total_discount_fee + apply) * 100) / 100;
}
return orderData;
}
// 场景 1:普通商品购买(¥1398.00),用户拥有 27 个兑换券,正常下单(不传 voucher_fee)
const scene1 = mockOrderCalc('goods', 27, 1398.00);
assert(scene1.voucher_fee === 0, '场景 1:普通商品购买 voucher_fee 为 0');
assert(scene1.voucher_available === 0, '场景 1:普通商品购买 voucher_available 为 0(不向前端泄露抵扣额)');
assert(scene1.pay_fee === 1398.00, '场景 1:普通商品购买应付现金全额保持 1398.00 元');
// 场景 2:普通商品购买(¥1398.00),攻击者尝试越权传参 voucher_fee = 27
const scene2 = mockOrderCalc('goods', 27, 1398.00, 27);
assert(scene2.voucher_fee === 0, '场景 2(恶意注入拦截):普通商品强行传 voucher_fee=27,被严格拦截置零');
assert(scene2.pay_fee === 1398.00, '场景 2(恶意注入拦截):应付金额分文不少,保持 1398.00 元');
// 场景 3:积分商城兑换(商品 50 元 + 10 积分),用户拥有 27 个兑换券,自动抵扣
const scene3 = mockOrderCalc('score', 27, 50.00);
assert(scene3.voucher_fee === 27, '场景 3:积分商城兑换成功抵扣 27 个兑换券');
assert(scene3.voucher_available === 27, '场景 3:积分商城兑换正常返回可用额度 27');
assert(scene3.pay_fee === 23.00, '场景 3:积分商品剩余 23.00 元现金需自付');
// 场景 4:积分商城兑换(商品 50 元 + 10 积分),用户拥有 100 个兑换券,全额抵扣现金
const scene4 = mockOrderCalc('score', 100, 50.00);
assert(scene4.voucher_fee === 50, '场景 4:积分商城兑换成功全额抵扣 50 个兑换券');
assert(scene4.pay_fee === 0.00, '场景 4:应付现金降至 0 元,结合 10 积分达成完全免现金兑换');
// 场景 5:模拟订单 ext 持久化安全
function mockSaveOrderExt(orderType, calcResultVoucherFee) {
return (orderType === 'score') ? (calcResultVoucherFee || 0) : 0;
}
assert(mockSaveOrderExt('goods', 100) === 0, '场景 5:普通订单 ext.voucher_fee 强制为 0');
assert(mockSaveOrderExt('score', 50) === 50, '场景 5:积分订单 ext.voucher_fee 正确记录为 50');
console.log('\n--------------------------------------------------------');
console.log(`测试执行结果: 通过 ${passCount} 项, 失败 ${failCount} 项`);
console.log('========================================================\n');
if (failCount > 0) {
process.exit(1);
}

查看文件

@@ -0,0 +1,102 @@
/**
* 兑换券售后退款与订单失效闭环返还专项自动化测试
*
* 验证重点:
* 1. Partner.php 监听器中 checkIsAllRefund 正确排除 REFUND_STATUS_AGREE (1) 与 COMPLETED (2),确保单品及全单退款顺利触发
* 2. Partner.php orderItemRefundAfter 增加 refundConsume 兜底返还已核销兑换券
* 3. Order.php invalid() 在订单关闭或取消时触发 refundConsume 兜底返还
* 4. 幂等防重与零元消费场景模拟
*/
const fs = require('fs');
const path = require('path');
let passCount = 0;
let failCount = 0;
function assert(condition, message) {
if (condition) {
console.log(` \x1b[32m[PASS]\x1b[0m ${message}`);
passCount++;
} else {
console.error(` \x1b[31m[FAIL]\x1b[0m ${message}`);
failCount++;
}
}
console.log('========================================================');
console.log(' 兑换券退款与失效闭环返还自动化验证');
console.log('========================================================\n');
// 1. Partner.php 监听器审查
const partnerListenerPath = path.resolve(__dirname, '../addons/shopro/listener/Partner.php');
assert(fs.existsSync(partnerListenerPath), 'Partner.php 监听器文件存在');
if (fs.existsSync(partnerListenerPath)) {
const content = fs.readFileSync(partnerListenerPath, 'utf-8');
assert(content.includes("(new VoucherService())->refundConsume($order)"), 'orderItemRefundAfter 显式调用 refundConsume 进行已核销额度原路退回');
assert(content.includes('REFUND_STATUS_AGREE'), 'checkIsAllRefund 包含 REFUND_STATUS_AGREE 排除判定');
assert(content.includes('REFUND_STATUS_COMPLETED'), 'checkIsAllRefund 包含 REFUND_STATUS_COMPLETED 排除判定');
assert(content.includes('whereNotIn'), 'checkIsAllRefund 采用 whereNotIn 精准排除已同意和已完成的退款');
}
// 2. Order.php 监听器审查
const orderListenerPath = path.resolve(__dirname, '../addons/shopro/listener/Order.php');
assert(fs.existsSync(orderListenerPath), 'Order.php 监听器文件存在');
if (fs.existsSync(orderListenerPath)) {
const content = fs.readFileSync(orderListenerPath, 'utf-8');
const invalidIdx = content.indexOf('function invalid(');
const invalidBlock = content.slice(invalidIdx, invalidIdx + 500);
assert(invalidBlock.includes('refundConsume($order)'), 'Order.php invalid() 包含兑换券原路退回调用');
}
// 3. VoucherService.php 幂等机制审查
const voucherServicePath = path.resolve(__dirname, '../addons/shopro/service/partner/VoucherService.php');
assert(fs.existsSync(voucherServicePath), 'VoucherService.php 文件存在');
if (fs.existsSync(voucherServicePath)) {
const content = fs.readFileSync(voucherServicePath, 'utf-8');
assert(content.includes('empty($ext[\'voucher_consumed\'])'), 'refundConsume 具备未核销拦截保护');
assert(content.includes('$alreadyRefunded'), 'refundConsume 具备已退款历史统计');
assert(content.includes('$remainVoucher'), 'refundConsume 严格限制单次退款不超过剩余可退额');
assert(content.includes('voucher_consume_refund'), 'refundConsume 记入流水日志');
}
// 4. 业务算法模拟
console.log('\n--- 业务算法与边界模拟 ---');
function mockRefundCycle(orderExt, alreadyRefundedSum, requestRefund) {
const totalVoucher = Number(orderExt.voucher_fee || 0);
const consumed = Boolean(orderExt.voucher_consumed);
if (totalVoucher <= 0 || !consumed) {
return { refundAmount: 0, reason: '无需退还' };
}
const remain = Math.max(0, totalVoucher - alreadyRefundedSum);
if (remain <= 0) {
return { refundAmount: 0, reason: '已全部退还,拦截重复' };
}
const fee = requestRefund !== null ? Math.min(remain, Number(requestRefund || 0)) : remain;
return { refundAmount: fee, reason: '成功退还' };
}
// 场景 1:已核销 50 个兑换券,首次全额退款
const r1 = mockRefundCycle({ voucher_fee: 50, voucher_consumed: 1 }, 0, null);
assert(r1.refundAmount === 50 && r1.reason === '成功退还', '场景 1:已核销订单成功全额退还 50 个兑换券');
// 场景 2:已核销 50 个兑换券,但已退款 50 个,重复触发退款
const r2 = mockRefundCycle({ voucher_fee: 50, voucher_consumed: 1 }, 50, null);
assert(r2.refundAmount === 0 && r2.reason === '已全部退还,拦截重复', '场景 2:幂等拦截!避免重复退还兑换券造成双重返还');
// 场景 3:普通商品订单(voucher_fee=0)触发退款
const r3 = mockRefundCycle({ voucher_fee: 0, voucher_consumed: 0 }, 0, null);
assert(r3.refundAmount === 0 && r3.reason === '无需退还', '场景 3:普通商品无兑换券核销,安全跳过');
// 场景 4:未支付关闭(voucher_consumed=0)触发退款
const r4 = mockRefundCycle({ voucher_fee: 50, voucher_consumed: 0 }, 0, null);
assert(r4.refundAmount === 0 && r4.reason === '无需退还', '场景 4:未付款即关闭的订单不退还兑换券(未发生实际扣减)');
console.log('\n--------------------------------------------------------');
console.log(`测试执行结果: 通过 ${passCount} 项, 失败 ${failCount} 项`);
console.log('========================================================');
if (failCount > 0) {
process.exit(1);
}

查看文件

@@ -44,7 +44,7 @@ if (fs.existsSync(voucherVuePath)) {
assert(content.includes('<text class="hero-unit">个</text>'), 'voucher.vue 核心大字数单位展示为「个」');
assert(!content.includes('<text class="hero-symbol">¥</text>'), 'voucher.vue 核心大字数移除「¥」符号');
assert(content.includes('<text class="metric-unit">个</text>'), 'voucher.vue 底部统计数据栏使用「个」单位');
assert(content.includes("formatVoucher(item.total_amount || item.total || item.voucher_total) }} 个") || content.includes("{{ item.total_amount || item.total || item.voucher_total || '0.00' }} 个"), 'voucher.vue 计划列表卡片使用「个」单位');
assert(content.includes("formatVoucher(item.total_amount || item.total || item.voucher_total) }} 个") || content.includes("{{ item.total_amount || item.total || item.voucher_total || '0.00' }} 个") || content.includes('<text class="metric-unit-text">个</text>'), 'voucher.vue 计划列表卡片使用「个」单位');
}
// 4. UniApp - confirm.vue

查看文件

@@ -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: {