feat: 完善商品计奖隔离、积分商城兑换券抵扣与下线售后退款入口

这个提交包含在:
chen
2026-09-17 16:55:08 +08:00
父节点 f5b804fb57
当前提交 c3f5b3d4b5
共有 22 个文件被更改,包括 720 次插入 和 183 次删除

查看文件

@@ -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;
}

查看文件

@@ -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;

查看文件

@@ -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]);

查看文件

@@ -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);

查看文件

@@ -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)

查看文件

@@ -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);
}
}

查看文件

@@ -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,
]);

查看文件

@@ -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);
}

查看文件

@@ -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);
}

查看文件

@@ -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('<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(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);
}

查看文件

@@ -1,7 +1,27 @@
<!-- 页面 -->
<template>
<s-layout title="积分商城">
<view class="ss-p-20">
<!-- 资产看板:当前积分与可用兑换券 -->
<view class="asset-banner ss-m-20 ss-p-20">
<view class="ss-flex ss-row-between ss-col-center">
<view class="asset-item">
<view class="asset-label">当前积分</view>
<view class="asset-val">{{ userInfo.score || 0 }}</view>
</view>
<view class="asset-line"></view>
<view class="asset-item">
<view class="asset-label">可用兑换券</view>
<view class="asset-val text-red">¥ {{ userVoucherAvailable }}</view>
</view>
<button class="ss-reset-button earn-btn" @tap="sheep.$router.go('/pages/commission/voucher')">
券明细
</button>
</view>
<view class="asset-tip ss-m-t-16">
💡 兑换商品时支持使用兑换券 1:1 抵扣现金部分
</view>
</view>
<view class="ss-p-x-20">
<view v-for="item in state.pagination.data" :key="item.id" class="ss-m-b-20">
<s-score-card
size="sl"
@@ -29,9 +49,15 @@
<script setup>
import sheep from '@/sheep';
import { onLoad, onReachBottom } from '@dcloudio/uni-app';
import { reactive } from 'vue';
import { computed, reactive } from 'vue';
import _ from 'lodash';
const userInfo = computed(() => sheep.$store('user').userInfo || {});
const agentInfo = computed(() => sheep.$store('user').agentInfo || {});
const userVoucherAvailable = computed(() => {
return agentInfo.value?.voucher_available || userInfo.value?.voucher_available || '0.00';
});
const state = reactive({
pagination: {
data: [],
@@ -75,3 +101,43 @@
getData();
});
</script>
<style lang="scss" scoped>
.asset-banner {
background: linear-gradient(135deg, #2d3139 0%, #1f2228 100%);
border-radius: 16rpx;
color: #fff;
.asset-item {
.asset-label {
font-size: 24rpx;
color: #999;
margin-bottom: 8rpx;
}
.asset-val {
font-size: 34rpx;
font-weight: bold;
color: #f7d088;
&.text-red {
color: #ff5252;
}
}
}
.asset-line {
width: 1rpx;
height: 48rpx;
background: rgba(255, 255, 255, 0.15);
}
.earn-btn {
padding: 8rpx 20rpx;
font-size: 24rpx;
color: #fff;
background: rgba(255, 255, 255, 0.15);
border-radius: 24rpx;
border: 1rpx solid rgba(255, 255, 255, 0.2);
}
.asset-tip {
font-size: 22rpx;
color: #d1b172;
}
}
</style>

查看文件

@@ -24,7 +24,7 @@
</view>
<button
class="ss-reset-button exchange-btn ss-flex ss-row-center ss-col-center"
@tap="sheep.$router.go('/pages/index/index')"
@tap="sheep.$router.go('/pages/app/score-shop')"
>
<text class="cicon-goods-o ss-m-r-6"></text>
<text>去兑换</text>

查看文件

@@ -39,6 +39,7 @@
<view class="score-text ss-m-r-16">
{{ goodsPrice.score }}
</view>
<view v-if="goodsPrice.price > 0" class="voucher-tip-tag">支持兑换券抵扣</view>
</view>
<view class="sales-text">
{{ formatExchange(state.goodsInfo.sales_show_type, state.goodsInfo.sales) }}
@@ -237,6 +238,17 @@
line-height: 36rpx;
font-family: OPPOSANS;
}
.voucher-tip-tag {
font-size: 20rpx;
color: #ff3000;
background: rgba(255, 48, 0, 0.1);
border: 1rpx solid rgba(255, 48, 0, 0.2);
border-radius: 6rpx;
padding: 2rpx 8rpx;
margin-left: 12rpx;
line-height: normal;
align-self: center;
}
}
.origin-price-text {
font-size: 26rpx;

查看文件

@@ -146,21 +146,11 @@ const rules = reactive({});
// 提交表单
async function submit() {
// #ifdef MP
sheep.$platform.useProvider('wechat').subscribeMessage('order_aftersale_change');
// #endif
let data = {
...formData,
order_id: state.goodsItem.order_id,
order_item_id: state.goodsItem.id,
};
const res = await sheep.$api.order.aftersale.apply(data);
if (res.code === 1) {
uni.showToast({
title: res.msg,
});
sheep.$router.go('/pages/order/aftersale/list');
}
uni.showToast({
title: '退款与售后功能暂未开放',
icon: 'none',
});
return;
}
//选择售后类型
function onRefundChange(e) {
@@ -182,7 +172,16 @@ function onReason() {
state.showModal = false;
}
onLoad((options) => {
state.goodsItem = JSON.parse(options.item);
uni.showToast({
title: '退款与售后功能暂未开放',
icon: 'none',
});
setTimeout(() => {
sheep.$router.back();
}, 1200);
if (options?.item) {
state.goodsItem = JSON.parse(options.item);
}
});
</script>

查看文件

@@ -196,8 +196,13 @@
state.loading = false;
}
onLoad((options) => {
state.aftersaleId = options.id;
getDetail(options.id);
uni.showToast({
title: '退款与售后功能暂未开放',
icon: 'none',
});
setTimeout(() => {
sheep.$router.back();
}, 1200);
});
</script>

查看文件

@@ -178,10 +178,13 @@
}
onLoad(async (options) => {
if (options.type) {
state.currentTab = options.type;
}
getOrderList();
uni.showToast({
title: '退款与售后功能暂未开放',
icon: 'none',
});
setTimeout(() => {
sheep.$router.back();
}, 1200);
});
// 加载更多

查看文件

@@ -91,8 +91,13 @@ async function onConfirm() {
}
onLoad((options) => {
state.aftersaleId = options.id;
getDetail(options.id);
uni.showToast({
title: '退款与售后功能暂未开放',
icon: 'none',
});
setTimeout(() => {
sheep.$router.back();
}, 1200);
});
</script>

查看文件

@@ -97,7 +97,6 @@
</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">

查看文件

@@ -91,40 +91,7 @@
</template>
<template #tool>
<view class="ss-flex">
<button
class="ss-reset-button apply-btn"
v-if="item.btns.includes('aftersale')"
@tap.stop="
sheep.$router.go('/pages/order/aftersale/apply', {
item: JSON.stringify(item),
})
"
>
申请售后
</button>
<button
class="ss-reset-button apply-btn"
v-if="item.btns.includes('re_aftersale')"
@tap.stop="
sheep.$router.go('/pages/order/aftersale/apply', {
item: JSON.stringify(item),
})
"
>
重新售后
</button>
<button
class="ss-reset-button apply-btn"
v-if="item.btns.includes('aftersale_info')"
@tap.stop="
sheep.$router.go('/pages/order/aftersale/detail', {
id: item.ext.aftersale_id,
})
"
>
售后详情
</button>
<button
class="ss-reset-button apply-btn"
v-if="item.btns.includes('buy_again')"
@@ -271,12 +238,7 @@
@tap="onPay(state.orderInfo.order_sn)"
>继续支付</button
>
<button
class="ss-reset-button cancel-btn"
v-if="state.orderInfo.btns?.includes('apply_refund')"
@tap="onRefund(state.orderInfo.id)"
>申请退款</button
>
<button
class="ss-reset-button cancel-btn"
v-if="state.orderInfo.btns?.includes('groupon')"
@@ -313,13 +275,7 @@
>
查看发票
</button>
<button
v-if="state.orderInfo.btns?.includes('re_apply_refund')"
class="ss-reset-button cancel-btn"
@tap.stop="onRefund(state.orderInfo.id)"
>
重新退款
</button>
<button
class="ss-reset-button cancel-btn"
v-if="state.orderInfo.btns?.includes('apply_invoice')"

查看文件

@@ -38,40 +38,7 @@
>
<template #tool>
<view class="ss-flex">
<button
class="ss-reset-button apply-btn"
v-if="item.btns.includes('aftersale')"
@tap.stop="
sheep.$router.go('/pages/order/aftersale/apply', {
item: JSON.stringify(item),
})
"
>
申请售后
</button>
<button
class="ss-reset-button apply-btn"
v-if="item.btns.includes('re_aftersale')"
@tap.stop="
sheep.$router.go('/pages/order/aftersale/apply', {
item: JSON.stringify(item),
})
"
>
重新售后
</button>
<button
class="ss-reset-button apply-btn"
v-if="item.btns.includes('aftersale_info')"
@tap.stop="
sheep.$router.go('/pages/order/aftersale/detail', {
id: item.ext.aftersale_id,
})
"
>
售后详情
</button>
<button
class="ss-reset-button apply-btn"
v-if="item.btns.includes('buy_again')"
@@ -168,20 +135,6 @@
查看物流
</button>
<button
v-if="order.btns.includes('apply_refund')"
class="tool-btn ss-reset-button"
@tap.stop="onRefund(order.id)"
>
申请退款
</button>
<button
v-if="order.btns.includes('re_apply_refund')"
class="tool-btn ss-reset-button"
@tap.stop="onRefund(order.id)"
>
重新退款
</button>
<button
v-if="order.btns.includes('cancel')"

查看文件

@@ -34,6 +34,13 @@
path: '/pages/order/list',
type: 'unpaid',
},
{
title: '待发货',
value: '2',
icon: '/assets/addons/shopro/uniapp/order/cargo.png',
path: '/pages/order/list',
type: 'nosend',
},
{
title: '待收货',
value: '3',
@@ -48,13 +55,6 @@
path: '/pages/order/list',
type: 'nocomment',
},
{
title: '售后单',
value: '0',
icon: '/assets/addons/shopro/uniapp/order/change_order.png',
path: '/pages/order/aftersale/list',
type: 'aftersale',
},
{
title: '全部订单',
value: '0',

查看文件

@@ -63,6 +63,15 @@ const _go = (
return;
}
// 退款与售后功能关闭拦截
if (startsWith(page, '/pages/order/aftersale')) {
uni.showToast({
title: '退款与售后功能暂未开放',
icon: 'none',
});
return;
}
url = page;
if (!isEmpty(query)) {
url += `?${query}`;