From c3f5b3d4b5368f1cfd5c9532272f9d2255e412fc Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 17 Sep 2026 16:55:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=95=86=E5=93=81?= =?UTF-8?q?=E8=AE=A1=E5=A5=96=E9=9A=94=E7=A6=BB=E3=80=81=E7=A7=AF=E5=88=86?= =?UTF-8?q?=E5=95=86=E5=9F=8E=E5=85=91=E6=8D=A2=E5=88=B8=E6=8A=B5=E6=89=A3?= =?UTF-8?q?=E4=B8=8E=E4=B8=8B=E7=BA=BF=E5=94=AE=E5=90=8E=E9=80=80=E6=AC=BE?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/shopro/listener/Partner.php | 9 +- addons/shopro/service/order/OrderCreate.php | 4 +- .../shopro/service/partner/ActiveService.php | 23 +-- .../service/partner/DailySettleService.php | 10 +- .../service/partner/PartnerRewardService.php | 78 ++++++--- addons/shopro/service/partner/RateService.php | 36 ++++ .../shopro/service/partner/VoucherService.php | 36 +++- .../verify_commission_goods_isolation_spec.js | 146 ++++++++++++++++ ...erify_disable_aftersale_and_refund_spec.js | 139 ++++++++++++++++ ...ify_score_shop_voucher_integration_spec.js | 157 ++++++++++++++++++ uniapp/pages/app/score-shop.vue | 72 +++++++- uniapp/pages/commission/voucher.vue | 2 +- uniapp/pages/goods/score.vue | 12 ++ uniapp/pages/order/aftersale/apply.vue | 31 ++-- uniapp/pages/order/aftersale/detail.vue | 9 +- uniapp/pages/order/aftersale/list.vue | 11 +- uniapp/pages/order/aftersale/log.vue | 9 +- uniapp/pages/order/confirm.vue | 1 - uniapp/pages/order/detail.vue | 48 +----- uniapp/pages/order/list.vue | 47 ------ .../components/s-order-card/s-order-card.vue | 14 +- uniapp/sheep/router/index.js | 9 + 22 files changed, 720 insertions(+), 183 deletions(-) create mode 100644 tests/verify_commission_goods_isolation_spec.js create mode 100644 tests/verify_disable_aftersale_and_refund_spec.js create mode 100644 tests/verify_score_shop_voucher_integration_spec.js diff --git a/addons/shopro/listener/Partner.php b/addons/shopro/listener/Partner.php index cfce3e1..343e092 100644 --- a/addons/shopro/listener/Partner.php +++ b/addons/shopro/listener/Partner.php @@ -14,9 +14,6 @@ class Partner public function orderPaidAfter($params) { $order = $params['order']; - if (($order['type'] ?? '') === 'score') { - return; - } $orderModel = is_object($order) ? $order : OrderModel::where('id', $order['id'] ?? 0)->find(); if ($orderModel) { @@ -26,6 +23,12 @@ class Partner format_log_error($e, 'Partner.consumeByOrder', '兑换券核销失败: ' . $e->getMessage()); } } + + // 积分商品订单不参与收益中心奖励结算 + if (($order['type'] ?? '') === 'score') { + return; + } + if (!PartnerConfig::isEnabled()) { return; } diff --git a/addons/shopro/service/order/OrderCreate.php b/addons/shopro/service/order/OrderCreate.php index 0c29197..c352fd0 100755 --- a/addons/shopro/service/order/OrderCreate.php +++ b/addons/shopro/service/order/OrderCreate.php @@ -607,11 +607,11 @@ class OrderCreate public function calcVoucher() { $available = 0; - if ($this->user && $this->user->id && $this->order_type !== 'score') { + if ($this->user && $this->user->id) { $available = (new \addons\shopro\service\partner\VoucherService())->available($this->user->id); } $this->orderData['voucher_available'] = $available; - $want = $this->order_type === 'score' ? 0 : max(0, round((float) $this->voucher_fee, 2)); + $want = max(0, round((float) $this->voucher_fee, 2)); $apply = min($want, (float) $available, (float) $this->orderData['pay_fee']); $apply = $apply > 0 ? $apply : 0; $this->orderData['voucher_fee'] = $apply; diff --git a/addons/shopro/service/partner/ActiveService.php b/addons/shopro/service/partner/ActiveService.php index 7a0e175..5c00342 100644 --- a/addons/shopro/service/partner/ActiveService.php +++ b/addons/shopro/service/partner/ActiveService.php @@ -38,12 +38,9 @@ class ActiveService public function hasQualifiedOrder($userId, $start, $end) { - $order = OrderModel::where('user_id', $userId) - ->where('type', '<>', 'score') - ->where('pay_fee', '>', 0) - ->paid() - ->where('paid_time', '>=', $start) - ->where('paid_time', '<=', $end) + $rateService = new RateService(); + $order = $rateService->qualifiedOrderQuery($start, $end) + ->where('o.user_id', $userId) ->find(); return (bool) $order; } @@ -72,15 +69,11 @@ class ActiveService })->column('user_id'); if (!empty($nullUserIds)) { - $orderHits = \think\Db::name('shopro_order') - ->whereIn('user_id', $nullUserIds) - ->where('type', '<>', 'score') - ->where('pay_fee', '>', 0) - ->whereIn('status', ['paid', 'completed']) - ->where('paid_time', '>=', $start) - ->where('paid_time', '<=', $end) - ->group('user_id') - ->column('max(paid_time)', 'user_id'); + $rateService = new RateService(); + $orderHits = $rateService->qualifiedOrderQuery($start, $end) + ->whereIn('o.user_id', $nullUserIds) + ->group('o.user_id') + ->column('max(o.paid_time)', 'o.user_id'); foreach ($orderHits as $uId => $lastTime) { \think\Db::name('shopro_commission_agent')->where('user_id', $uId)->update(['last_self_order_time' => $lastTime]); diff --git a/addons/shopro/service/partner/DailySettleService.php b/addons/shopro/service/partner/DailySettleService.php index 01f964e..9e11457 100644 --- a/addons/shopro/service/partner/DailySettleService.php +++ b/addons/shopro/service/partner/DailySettleService.php @@ -64,10 +64,7 @@ class DailySettleService $orders = $rateService->qualifiedOrderQuery($start, $end)->field('o.id,o.pay_fee')->select(); $orderCount = count($orders); - $gmv = 0; - foreach ($orders as $order) { - $gmv = bcadd((string) $gmv, (string) $order['pay_fee'], 2); - } + $gmv = $rateService->qualifiedGmv($start, $end); $windowStart = strtotime($settleDate . ' 00:00:00') - (PartnerConfig::activeDays() - 1) * 86400; $tasterIds = $active->activeUserIds($windowStart, $end); @@ -214,10 +211,7 @@ class DailySettleService $orders = $rateService->qualifiedOrderQuery($start, $end)->field('o.id,o.pay_fee')->select(); $orderCount = count($orders); - $gmv = 0; - foreach ($orders as $order) { - $gmv = bcadd((string) $gmv, (string) $order['pay_fee'], 2); - } + $gmv = $rateService->qualifiedGmv($start, $end); $windowStart = strtotime($settleDate . ' 00:00:00') - (PartnerConfig::activeDays() - 1) * 86400; $tasterIds = $active->activeUserIds($windowStart, $end); diff --git a/addons/shopro/service/partner/PartnerRewardService.php b/addons/shopro/service/partner/PartnerRewardService.php index 10b7241..54e2646 100644 --- a/addons/shopro/service/partner/PartnerRewardService.php +++ b/addons/shopro/service/partner/PartnerRewardService.php @@ -101,9 +101,23 @@ class PartnerRewardService return Db::transaction(function () use ($order, $item) { $orderId = intval(is_array($order) ? ($order['id'] ?? 0) : ($order->id ?? 0)); $itemPayFee = floatval(is_array($item) ? ($item['pay_fee'] ?? 0) : ($item->pay_fee ?? 0)); + $goodsId = intval(is_array($item) ? ($item['goods_id'] ?? 0) : ($item->goods_id ?? 0)); + $ext = is_array($item['ext'] ?? null) ? $item['ext'] : (json_decode($item['ext'] ?? '', true) ?: []); + $isCommissionItem = true; + if (isset($ext['is_commission']) && !$ext['is_commission']) { + $isCommissionItem = false; + } elseif ($goodsId > 0) { + $cg = CommissionGoods::where('goods_id', $goodsId)->where('status', CommissionGoods::GOODS_COMMISSION_STATUS_ON)->find(); + if (!$cg) { + $isCommissionItem = false; + } + } else { + $isCommissionItem = false; + } + $voucherService = new VoucherService(); - if ($itemPayFee > 0) { + if ($itemPayFee > 0 && $isCommissionItem) { $voucherService->reduceByRefund($orderId, $itemPayFee); } @@ -116,7 +130,7 @@ class PartnerRewardService 'amount' => 0, 'base_amount' => $itemPayFee, 'ext' => [ - 'formula' => "子商品退款 {$itemPayFee},缩减兑换券总额", + 'formula' => $isCommissionItem ? "计奖商品退款 {$itemPayFee},缩减兑换券总额" : "非计奖商品退款 {$itemPayFee},无需缩减兑换券", 'item_id' => $item['id'] ?? 0, 'partial_refund' => 1 ], @@ -127,6 +141,42 @@ class PartnerRewardService }); } + /** + * 获取订单中参与计奖的商品实付金额之和(排除非计奖商品及活动不参与项) + */ + public function getQualifiedPayFee($order) + { + if (!$order || ($order['type'] ?? '') === 'score') { + return 0.0; + } + $items = $order['items'] ?? []; + if (empty($items) && !empty($order['id'])) { + $fresh = OrderModel::with(['items'])->where('id', $order['id'])->find(); + if ($fresh) { + $order = $fresh; + $items = $fresh['items'] ?? []; + } + } + $total = 0.0; + foreach ($items as $item) { + $ext = is_array($item['ext'] ?? null) ? $item['ext'] : (json_decode($item['ext'] ?? '', true) ?: []); + if (isset($ext['is_commission']) && !$ext['is_commission']) { + continue; + } + $goods = CommissionGoods::where('goods_id', $item['goods_id']) + ->where('status', CommissionGoods::GOODS_COMMISSION_STATUS_ON) + ->find(); + if ($goods) { + $itemPay = floatval($item['pay_fee'] ?? 0); + $total = function_exists('bcadd') ? (float) bcadd((string) $total, (string) $itemPay, 2) : round($total + $itemPay, 2); + } + } + // 计奖实付金额上限不得超过整单实际现金支付金额(防止使用兑换券/整单折扣抵扣后对券抵扣部分重复计奖) + $orderPayFee = max(0, floatval($order['pay_fee'] ?? 0)); + $total = min($total, $orderPayFee); + return (float) $total; + } + public function isQualified($order) { if (!$order || ($order['type'] ?? '') === 'score') { @@ -139,26 +189,12 @@ class PartnerRewardService if (!in_array($status, [OrderModel::STATUS_PAID, OrderModel::STATUS_COMPLETED])) { return false; } - $items = $order['items'] ?? []; - if (empty($items) && !empty($order['id'])) { - $fresh = OrderModel::with(['items'])->where('id', $order['id'])->find(); - if ($fresh) { - $order = $fresh; - $items = $fresh['items'] ?? []; - } + $qualifiedPayFee = $this->getQualifiedPayFee($order); + $minAmount = (float) PartnerConfig::get('partner_order_min_amount', 0); + if ($minAmount > 0) { + return $qualifiedPayFee >= $minAmount; } - foreach ($items as $item) { - if (isset($item['ext']['is_commission']) && !$item['ext']['is_commission']) { - continue; - } - $goods = CommissionGoods::where('goods_id', $item['goods_id']) - ->where('status', CommissionGoods::GOODS_COMMISSION_STATUS_ON) - ->find(); - if ($goods) { - return true; - } - } - return false; + return $qualifiedPayFee > 0; } public function touchSelfOrder($userId, $time = null) diff --git a/addons/shopro/service/partner/RateService.php b/addons/shopro/service/partner/RateService.php index 93bd25d..db3594b 100644 --- a/addons/shopro/service/partner/RateService.php +++ b/addons/shopro/service/partner/RateService.php @@ -88,4 +88,40 @@ class RateService } return $query; } + + /** + * 汇总指定时间区间内所有合格订单中“计奖商品”的实付总额(排除非计奖商品及活动不参与项) + */ + public function qualifiedGmv($start = null, $end = null) + { + $goodsTable = (new CommissionGoods())->getQuery()->getTable(); + $orderTable = (new OrderModel())->getQuery()->getTable(); + $itemTable = \think\Db::name('shopro_order_item')->getTable(); + + $query = \think\Db::table($itemTable)->alias('oi') + ->join($orderTable . ' o', 'o.id = oi.order_id') + ->join($goodsTable . ' cg', 'cg.goods_id = oi.goods_id') + ->where('o.type', '<>', 'score') + ->where('o.pay_fee', '>', 0) + ->whereIn('o.status', [OrderModel::STATUS_PAID, OrderModel::STATUS_COMPLETED]) + ->where('cg.status', CommissionGoods::GOODS_COMMISSION_STATUS_ON) + ->where(function ($q) { + $q->whereNull('oi.ext') + ->whereOr('oi.ext', '') + ->whereOr(function ($sq) { + $sq->where('oi.ext', 'not like', '%"is_commission":false%') + ->where('oi.ext', 'not like', '%"is_commission":0%'); + }); + }); + + if ($start !== null) { + $query->where('o.paid_time', '>=', $start); + } + if ($end !== null) { + $query->where('o.paid_time', '<=', $end); + } + + $gmv = $query->sum('oi.pay_fee'); + return round((float) $gmv, 2); + } } diff --git a/addons/shopro/service/partner/VoucherService.php b/addons/shopro/service/partner/VoucherService.php index 4904a6c..254295c 100644 --- a/addons/shopro/service/partner/VoucherService.php +++ b/addons/shopro/service/partner/VoucherService.php @@ -60,12 +60,23 @@ class VoucherService if ($totalVoucher <= 0 || empty($ext['voucher_consumed'])) { return; } - $fee = $refundFee !== null ? min($totalVoucher, round((float) $refundFee, 2)) : $totalVoucher; + $userId = intval($order['user_id'] ?? 0); + $orderId = intval($order['id'] ?? 0); + + // 幂等防重:检查是否已退回过兑换券,避免多次退款事件重复返还额度 + $alreadyRefunded = (float) RewardModel::where('order_id', $orderId) + ->where('reward_type', 'voucher_consume_refund') + ->sum('amount'); + $remainVoucher = max(0, round((float) $totalVoucher - $alreadyRefunded, 2)); + if ($remainVoucher <= 0) { + return; + } + + $fee = $refundFee !== null ? min($remainVoucher, round((float) $refundFee, 2)) : $remainVoucher; if ($fee <= 0) { return; } - $userId = intval($order['user_id'] ?? 0); - $orderId = intval($order['id'] ?? 0); + $agent = AgentModel::where('user_id', $userId)->lock(true)->find(); if ($agent) { $agent->voucher_used = max(0, round((float) $agent->voucher_used - $fee, 2)); @@ -82,6 +93,15 @@ class VoucherService 'ext' => ['formula' => "订单退款退回兑换券 {$fee}", 'order_id' => $orderId], 'status' => 1, ]); + + if (is_object($order) && method_exists($order, 'save')) { + $ext['voucher_refunded'] = round($alreadyRefunded + $fee, 2); + if (bccomp((string) $ext['voucher_refunded'], (string) $totalVoucher, 2) >= 0) { + $ext['voucher_consumed'] = 0; + } + $order->ext = $ext; + $order->save(); + } } } @@ -113,7 +133,9 @@ class VoucherService $times = (float) PartnerConfig::get('partner_voucher_times', 1.5); $days = intval(PartnerConfig::get('partner_voucher_days', 365)); $days = $days > 0 ? $days : 365; - $total = round((float) $order['pay_fee'] * $times, 2); + $rewardService = new PartnerRewardService(); + $qualifiedPayFee = $rewardService->getQualifiedPayFee($order); + $total = round((float) $qualifiedPayFee * $times, 2); if ($total <= 0) { return null; } @@ -146,7 +168,11 @@ class VoucherService 'reward_type' => 'voucher_grant', 'amount' => 0, 'base_amount' => $total, - 'ext' => ['formula' => "实付{$order['pay_fee']} × {$times} = {$total},{$days}天释放"], + 'ext' => [ + 'formula' => "计奖实付{$qualifiedPayFee} × {$times} = {$total},{$days}天释放", + 'qualified_pay_fee' => $qualifiedPayFee, + 'order_pay_fee' => $order['pay_fee'] ?? 0 + ], 'status' => 1, ]); diff --git a/tests/verify_commission_goods_isolation_spec.js b/tests/verify_commission_goods_isolation_spec.js new file mode 100644 index 0000000..587fed4 --- /dev/null +++ b/tests/verify_commission_goods_isolation_spec.js @@ -0,0 +1,146 @@ +/** + * 计奖商品收益隔离与混合下单防套利专项验证测试 + * 覆盖范围:PartnerRewardService, VoucherService, RateService, DailySettleService, ActiveService + */ + +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. PartnerRewardService.php 验证 +const partnerRewardServicePath = path.resolve(__dirname, '../addons/shopro/service/partner/PartnerRewardService.php'); +assert(fs.existsSync(partnerRewardServicePath), 'PartnerRewardService.php 文件存在'); +if (fs.existsSync(partnerRewardServicePath)) { + const content = fs.readFileSync(partnerRewardServicePath, 'utf-8'); + assert(content.includes('public function getQualifiedPayFee'), '包含 getQualifiedPayFee() 方法'); + assert(content.includes('CommissionGoods::GOODS_COMMISSION_STATUS_ON'), 'getQualifiedPayFee 校验商品计奖启用状态'); + assert(content.includes("isset($ext['is_commission']) && !$ext['is_commission']"), 'getQualifiedPayFee 排除活动不可计奖商品项'); + assert(content.includes('$qualifiedPayFee = $this->getQualifiedPayFee($order)'), 'isQualified() 复用 getQualifiedPayFee()'); + assert(content.includes("PartnerConfig::get('partner_order_min_amount'"), 'isQualified() 支持配置订单最低计奖金额门槛'); + assert(content.includes('$isCommissionItem'), 'refundPartial() 判定子商品是否为计奖商品'); + assert(content.includes('if ($itemPayFee > 0 && $isCommissionItem)'), 'refundPartial() 仅对计奖商品缩减兑换券'); +} + +// 2. 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('$rewardService->getQualifiedPayFee($order)'), 'grant() 使用 getQualifiedPayFee 获取计奖实付金额'); + assert(content.includes('$total = round((float) $qualifiedPayFee * $times, 2)'), '兑换券总额基于计奖实付金额而非整单实付'); + assert(content.includes("计奖实付{$qualifiedPayFee}"), '兑换券日志 formula 明确标记计奖实付基数'); + assert(content.includes("'qualified_pay_fee' => $qualifiedPayFee"), '兑换券日志 ext 记录 qualified_pay_fee'); +} + +// 3. RateService.php 验证 +const rateServicePath = path.resolve(__dirname, '../addons/shopro/service/partner/RateService.php'); +assert(fs.existsSync(rateServicePath), 'RateService.php 文件存在'); +if (fs.existsSync(rateServicePath)) { + const content = fs.readFileSync(rateServicePath, 'utf-8'); + assert(content.includes('public function qualifiedGmv'), 'RateService 包含 qualifiedGmv() 方法'); + assert(content.includes('CommissionGoods::GOODS_COMMISSION_STATUS_ON'), 'qualifiedGmv 关联计奖状态表'); + assert(content.includes('"is_commission":false'), 'qualifiedGmv SQL 排除非分销活动项'); + assert(content.includes("$query->sum('oi.pay_fee')"), 'qualifiedGmv 针对 oi.pay_fee 单品金额求和'); +} + +// 4. DailySettleService.php 验证 +const dailySettleServicePath = path.resolve(__dirname, '../addons/shopro/service/partner/DailySettleService.php'); +assert(fs.existsSync(dailySettleServicePath), 'DailySettleService.php 文件存在'); +if (fs.existsSync(dailySettleServicePath)) { + const content = fs.readFileSync(dailySettleServicePath, 'utf-8'); + assert(content.includes('$gmv = $rateService->qualifiedGmv($start, $end)'), 'DailySettleService 日结 GMV 改用 qualifiedGmv 计算'); + assert(!content.includes("bcadd((string) $gmv, (string) $order['pay_fee']"), 'DailySettleService 彻底移除按整单累加 GMV 的漏洞逻辑'); +} + +// 5. ActiveService.php 验证 +const activeServicePath = path.resolve(__dirname, '../addons/shopro/service/partner/ActiveService.php'); +assert(fs.existsSync(activeServicePath), 'ActiveService.php 文件存在'); +if (fs.existsSync(activeServicePath)) { + const content = fs.readFileSync(activeServicePath, 'utf-8'); + assert(content.includes('$rateService->qualifiedOrderQuery($start, $end)'), 'ActiveService 兜底保活查询复用 qualifiedOrderQuery'); +} + +// 6. 算法逻辑模拟运行断言 +console.log('\n--- 场景模拟算法断言 ---'); + +function mockGetQualifiedPayFee(order, commissionGoodsMap) { + let total = 0; + const items = order.items || []; + for (const item of items) { + if (item.ext && item.ext.is_commission === false) { + continue; + } + if (commissionGoodsMap[item.goods_id] && commissionGoodsMap[item.goods_id].status === 1) { + total += Number(item.pay_fee || 0); + } + } + return Math.round(total * 100) / 100; +} + +const mockCommissionGoods = { + 101: { status: 1 }, // 指定计奖酒水 + 102: { status: 0 }, // 未开启计奖 + // 103 不在映射表中,相当于新添加未配置的商品 +}; + +// 场景 A:纯非计奖新品下单(商品 103,实付 500 元) +const pureNewOrder = { + id: 1, + pay_fee: 500, + items: [ + { goods_id: 103, pay_fee: 500, ext: {} } + ] +}; +const pureFee = mockGetQualifiedPayFee(pureNewOrder, mockCommissionGoods); +assert(pureFee === 0, '模拟场景 A:纯新品订单计奖实付金额为 0'); +assert(pureFee > 0 === false, '模拟场景 A:纯新品订单无法通过 isQualified 校验'); + +// 场景 B:混合下单(商品 101 计奖 100 元,商品 103 新品 900 元,整单实付 1000 元) +const mixedOrder = { + id: 2, + pay_fee: 1000, + items: [ + { goods_id: 101, pay_fee: 100, ext: {} }, + { goods_id: 103, pay_fee: 900, ext: {} } + ] +}; +const mixedFee = mockGetQualifiedPayFee(mixedOrder, mockCommissionGoods); +assert(mixedFee === 100, '模拟场景 B:混合订单精确提取计奖实付 100 元(排除新品 900 元)'); +const voucherTimes = 1.5; +const voucherTotal = Math.round(mixedFee * voucherTimes * 100) / 100; +assert(voucherTotal === 150, '模拟场景 B:兑换券按 100 * 1.5 = 150 发放(修复前会发 1500,成功堵住漏洞)'); + +// 场景 C:活动标记不参与分销(商品 101 秒杀特惠 is_commission=false) +const promoOrder = { + id: 3, + pay_fee: 100, + items: [ + { goods_id: 101, pay_fee: 100, ext: { is_commission: false } } + ] +}; +const promoFee = mockGetQualifiedPayFee(promoOrder, mockCommissionGoods); +assert(promoFee === 0, '模拟场景 C:即便属于计奖商品,活动标记 is_commission=false 亦被正确排除'); + +console.log('\n--------------------------------------------------------'); +console.log(`测试执行结果: 通过 ${passCount} 项, 失败 ${failCount} 项`); +console.log('========================================================'); + +if (failCount > 0) { + process.exit(1); +} diff --git a/tests/verify_disable_aftersale_and_refund_spec.js b/tests/verify_disable_aftersale_and_refund_spec.js new file mode 100644 index 0000000..7c93260 --- /dev/null +++ b/tests/verify_disable_aftersale_and_refund_spec.js @@ -0,0 +1,139 @@ +/** + * uniapp 退款与售后功能关闭专项自动化验证测试 + * 覆盖文件: + * 1. uniapp/sheep/components/s-order-card/s-order-card.vue (个人中心订单卡片) + * 2. uniapp/pages/order/list.vue (订单列表) + * 3. uniapp/pages/order/detail.vue (订单详情) + * 4. uniapp/sheep/router/index.js (全局路由拦截) + * 5. uniapp/pages/order/aftersale/apply.vue (售后申请页面兜底防护) + * 6. uniapp/pages/order/aftersale/list.vue (售后列表页面兜底防护) + * 7. uniapp/pages/order/aftersale/detail.vue (售后详情页面兜底防护) + * 8. uniapp/pages/order/aftersale/log.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(' uniapp 退款与售后功能关闭验证测试'); +console.log('========================================================\n'); + +// 1. 个人中心订单卡片 (s-order-card.vue) +const orderCardPath = path.resolve(__dirname, '../uniapp/sheep/components/s-order-card/s-order-card.vue'); +assert(fs.existsSync(orderCardPath), 's-order-card.vue 文件存在'); +if (fs.existsSync(orderCardPath)) { + const content = fs.readFileSync(orderCardPath, 'utf-8'); + assert(!content.includes("title: '售后单'"), 's-order-card 不再包含“售后单”菜单项'); + assert(!content.includes("type: 'aftersale'"), 's-order-card 不再包含 aftersale 类型的角标'); + assert(content.includes("title: '待发货'"), 's-order-card 已平滑切换为“待发货”'); + assert(content.includes("type: 'nosend'"), 's-order-card “待发货”绑定 nosend 待发货角标'); + assert(content.includes("value: '2'"), 's-order-card “待发货”指向 tabMaps 对应索引'); +} + +// 2. 订单列表 (list.vue) +const orderListPath = path.resolve(__dirname, '../uniapp/pages/order/list.vue'); +assert(fs.existsSync(orderListPath), 'order/list.vue 文件存在'); +if (fs.existsSync(orderListPath)) { + const content = fs.readFileSync(orderListPath, 'utf-8'); + assert(!content.includes("btns.includes('aftersale')"), 'order/list.vue 商品条目已移除“申请售后”按钮'); + assert(!content.includes("btns.includes('re_aftersale')"), 'order/list.vue 商品条目已移除“重新售后”按钮'); + assert(!content.includes("btns.includes('aftersale_info')"), 'order/list.vue 商品条目已移除“售后详情”按钮'); + assert(!content.includes("btns.includes('apply_refund')"), 'order/list.vue 订单条目已移除“申请退款”按钮'); + assert(!content.includes("btns.includes('re_apply_refund')"), 'order/list.vue 订单条目已移除“重新退款”按钮'); +} + +// 3. 订单详情 (detail.vue) +const orderDetailPath = path.resolve(__dirname, '../uniapp/pages/order/detail.vue'); +assert(fs.existsSync(orderDetailPath), 'order/detail.vue 文件存在'); +if (fs.existsSync(orderDetailPath)) { + const content = fs.readFileSync(orderDetailPath, 'utf-8'); + assert(!content.includes("btns.includes('aftersale')"), 'order/detail.vue 商品卡片已移除“申请售后”按钮'); + assert(!content.includes("btns.includes('re_aftersale')"), 'order/detail.vue 商品卡片已移除“重新售后”按钮'); + assert(!content.includes("btns.includes('aftersale_info')"), 'order/detail.vue 商品卡片已移除“售后详情”按钮'); + assert(!content.includes("btns?.includes('apply_refund')"), 'order/detail.vue 底部操作栏已移除“申请退款”按钮'); + assert(!content.includes("btns?.includes('re_apply_refund')"), 'order/detail.vue 底部操作栏已移除“重新退款”按钮'); +} + +// 4. 全局路由拦截 (router/index.js) +const routerPath = path.resolve(__dirname, '../uniapp/sheep/router/index.js'); +assert(fs.existsSync(routerPath), 'router/index.js 文件存在'); +if (fs.existsSync(routerPath)) { + const content = fs.readFileSync(routerPath, 'utf-8'); + assert( + content.includes("startsWith(page, '/pages/order/aftersale')"), + 'router/index.js 已拦截 /pages/order/aftersale 路由' + ); + assert( + content.includes('退款与售后功能暂未开放'), + 'router/index.js 具备友好中文拦截提示' + ); +} + +// 5. 售后申请页兜底 (aftersale/apply.vue) +const applyPath = path.resolve(__dirname, '../uniapp/pages/order/aftersale/apply.vue'); +assert(fs.existsSync(applyPath), 'aftersale/apply.vue 文件存在'); +if (fs.existsSync(applyPath)) { + const content = fs.readFileSync(applyPath, 'utf-8'); + assert( + content.includes('退款与售后功能暂未开放'), + 'aftersale/apply.vue onLoad 与 submit 具备拦截与防提交处理' + ); + assert( + content.includes('sheep.$router.back()'), + 'aftersale/apply.vue 具备自动退回上一页保护' + ); +} + +// 6. 售后列表页兜底 (aftersale/list.vue) +const aftersaleListPath = path.resolve(__dirname, '../uniapp/pages/order/aftersale/list.vue'); +assert(fs.existsSync(aftersaleListPath), 'aftersale/list.vue 文件存在'); +if (fs.existsSync(aftersaleListPath)) { + const content = fs.readFileSync(aftersaleListPath, 'utf-8'); + assert( + content.includes('退款与售后功能暂未开放') && content.includes('sheep.$router.back()'), + 'aftersale/list.vue 具备自动退回上一页保护' + ); +} + +// 7. 售后详情页兜底 (aftersale/detail.vue) +const aftersaleDetailPath = path.resolve(__dirname, '../uniapp/pages/order/aftersale/detail.vue'); +assert(fs.existsSync(aftersaleDetailPath), 'aftersale/detail.vue 文件存在'); +if (fs.existsSync(aftersaleDetailPath)) { + const content = fs.readFileSync(aftersaleDetailPath, 'utf-8'); + assert( + content.includes('退款与售后功能暂未开放') && content.includes('sheep.$router.back()'), + 'aftersale/detail.vue 具备自动退回上一页保护' + ); +} + +// 8. 售后进度日志页兜底 (aftersale/log.vue) +const aftersaleLogPath = path.resolve(__dirname, '../uniapp/pages/order/aftersale/log.vue'); +assert(fs.existsSync(aftersaleLogPath), 'aftersale/log.vue 文件存在'); +if (fs.existsSync(aftersaleLogPath)) { + const content = fs.readFileSync(aftersaleLogPath, 'utf-8'); + assert( + content.includes('退款与售后功能暂未开放') && content.includes('sheep.$router.back()'), + 'aftersale/log.vue 具备自动退回上一页保护' + ); +} + +console.log('\n========================================================'); +console.log(` 测试结果: 共 ${passCount + failCount} 项, 通过: ${passCount}, 失败: ${failCount}`); +console.log('========================================================\n'); + +if (failCount > 0) { + process.exit(1); +} diff --git a/tests/verify_score_shop_voucher_integration_spec.js b/tests/verify_score_shop_voucher_integration_spec.js new file mode 100644 index 0000000..9e16400 --- /dev/null +++ b/tests/verify_score_shop_voucher_integration_spec.js @@ -0,0 +1,157 @@ +/** + * 积分商城与兑换券完整关联专项自动化测试 + * 覆盖范围:OrderCreate, Partner listener, confirm.vue, voucher.vue, score-shop.vue, score.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. 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'); + assert(!content.includes("$this->order_type !== 'score'"), 'calcVoucher 移除了对 score 订单可用额度查询的封锁'); + assert(!content.includes("$this->order_type === 'score' ? 0"), '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 并参与应付现金抵扣'); +} + +// 2. 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'); + const consumePos = content.indexOf('(new VoucherService())->consumeByOrder($orderModel)'); + const scoreReturnPos = content.indexOf("if (($order['type'] ?? '') === 'score')"); + assert(consumePos !== -1, 'orderPaidAfter 包含 consumeByOrder 核销调用'); + assert(scoreReturnPos !== -1, 'orderPaidAfter 包含 score 类型拦截'); + assert(consumePos < scoreReturnPos, '兑换券核销 consumeByOrder 优先于 score 类型 return,确保积分订单成功核销兑换券'); +} + +// 2.1 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('voucher_consume_refund'), 'refundConsume 记录兑换券退款流水'); + assert(content.includes('$alreadyRefunded'), 'refundConsume 具备幂等防重金额统计'); + assert(content.includes('$remainVoucher'), 'refundConsume 严格限制退回上限不超过剩余未退额'); +} + +// 3. 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 voucherBlock = content.slice(content.indexOf('兑换券') - 100, content.indexOf('兑换券')); + assert(!voucherBlock.includes("order_type != 'score'"), 'confirm.vue 移除了兑换券行的 order_type != score 隐藏限制'); + assert(content.includes('@tap="openVoucher"'), '确认订单页提供 openVoucher 交互入口'); + assert(content.includes('state.orderInfo.voucher_available'), '展示可用兑换券额度'); +} + +// 4. 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("sheep.$router.go('/pages/app/score-shop')"), '兑换券中心「去兑换」按钮成功直达积分商城'); +} + +// 5. score-shop.vue 验证 +const scoreShopVuePath = path.resolve(__dirname, '../uniapp/pages/app/score-shop.vue'); +assert(fs.existsSync(scoreShopVuePath), 'score-shop.vue 文件存在'); +if (fs.existsSync(scoreShopVuePath)) { + const content = fs.readFileSync(scoreShopVuePath, 'utf-8'); + assert(content.includes('userVoucherAvailable'), '积分商城首页引入可用兑换券计算属性'); + assert(content.includes('当前积分'), '展示当前积分余额看板'); + assert(content.includes('可用兑换券'), '展示可用兑换券余额看板'); + assert(content.includes('💡 兑换商品时支持使用兑换券 1:1 抵扣现金部分'), '包含兑换券抵扣说明提示'); + assert(content.includes('/pages/commission/voucher'), '提供快捷跳转兑换券明细入口'); +} + +// 6. score.vue 验证 +const scoreDetailVuePath = path.resolve(__dirname, '../uniapp/pages/goods/score.vue'); +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('支持兑换券抵扣'), '积分商品详情标明支持兑换券抵扣现金'); +} + +// 7. 结算抵扣算法模拟 +console.log('\n--- 积分商城订单兑换券抵扣算法模拟 ---'); + +function mockCalcVoucher(orderType, userVoucherAvailable, orderPayFee, inputVoucherFee) { + let available = userVoucherAvailable; + let want = Math.max(0, Number(inputVoucherFee || 0)); + let apply = Math.min(want, available, orderPayFee); + apply = apply > 0 ? apply : 0; + let finalPayFee = Math.round((orderPayFee - apply) * 100) / 100; + return { + applyVoucher: apply, + finalPayFee: finalPayFee + }; +} + +// 场景 1:积分商品需 10 积分 + 50 元现金,用户持有 100 元兑换券,申请全额抵扣 50 元 +const case1 = mockCalcVoucher('score', 100, 50, 50); +assert(case1.applyVoucher === 50, '场景 1:成功申请抵扣 50 元兑换券'); +assert(case1.finalPayFee === 0, '场景 1:现金实付降为 0 元(纯积分 + 兑换券换走)'); + +// 场景 2:积分商品需 10 积分 + 50 元现金,用户仅持有 30 元兑换券,申请抵扣 50 元 +const case2 = mockCalcVoucher('score', 30, 50, 50); +assert(case2.applyVoucher === 30, '场景 2:受限于可用额度,最多抵扣 30 元'); +assert(case2.finalPayFee === 20, '场景 2:差额 20 元需用现金/微信支付'); + +// 场景 3:纯积分商品(100 积分 + 0 元现金) +const case3 = mockCalcVoucher('score', 100, 0, 50); +assert(case3.applyVoucher === 0, '场景 3:应付现金为 0 时,不误扣兑换券'); +assert(case3.finalPayFee === 0, '场景 3:现金实付保持 0 元'); + +// 场景 4:退款防重复返还(幂等保护)模拟 +function mockRefundConsume(totalVoucher, alreadyRefunded, requestRefundFee = null) { + const remain = Math.max(0, totalVoucher - alreadyRefunded); + if (remain <= 0) return 0; + const fee = requestRefundFee !== null ? Math.min(remain, requestRefundFee) : remain; + return fee > 0 ? fee : 0; +} +const refundRound1 = mockRefundConsume(50, 0); +assert(refundRound1 === 50, '场景 4:首次全额退款成功返还 50 元兑换券'); +const refundRound2 = mockRefundConsume(50, 50); // 重复触发 +assert(refundRound2 === 0, '场景 4:幂等拦截!重复退款触发时不再返还兑换券,避免双倍扣减已用额度'); + +// 场景 5:防套利保护——兑换券抵扣部分不计入新兑换券返还基数 +function mockGetQualifiedPayFee(orderType, itemsTotal, orderPayFee) { + if (orderType === 'score') return 0; + return Math.min(itemsTotal, Math.max(0, orderPayFee)); +} +const qualifiedCashOnly = mockGetQualifiedPayFee('normal', 100, 80); // 100元商品,20元券抵扣,实付80 +assert(qualifiedCashOnly === 80, '场景 5:100元商品使用20元券后,计奖实付严格锁定在80元现金'); +const qualifiedZeroCash = mockGetQualifiedPayFee('normal', 100, 0); // 100元券全额抵扣,实付0 +assert(qualifiedZeroCash === 0, '场景 5:全额使用兑换券抵扣时,计奖实付为0,杜绝凭空无限刷券套利'); + +console.log('\n--------------------------------------------------------'); +console.log(`测试执行结果: 通过 ${passCount} 项, 失败 ${failCount} 项`); +console.log('========================================================'); + +if (failCount > 0) { + process.exit(1); +} diff --git a/uniapp/pages/app/score-shop.vue b/uniapp/pages/app/score-shop.vue index 7aeb65e..345d570 100644 --- a/uniapp/pages/app/score-shop.vue +++ b/uniapp/pages/app/score-shop.vue @@ -1,7 +1,27 @@ -