fix: 优化退款防重与防多退机制,增加防重锁、事务排他行锁及汇付幂等对齐
这个提交包含在:
@@ -27,7 +27,57 @@ class HuifuRefundService
|
||||
if ($splitOrder && !empty($splitOrder['confirm_hf_seq_id'])) {
|
||||
return $this->confirmRefund($ext, $splitOrder, $refundLog, $refundMoney, $remark);
|
||||
}
|
||||
return $this->scanpayRefund($ext, $refundLog, $refundMoney, $remark);
|
||||
try {
|
||||
return $this->scanpayRefund($ext, $refundLog, $refundMoney, $remark);
|
||||
} catch (\Throwable $e) {
|
||||
// 如果汇付报错 23000003(申请退款金额大于可退款余额),核查是否此前已成功退款(幂等对齐防多退)
|
||||
if (strpos($e->getMessage(), '23000003') !== false || strpos($e->getMessage(), '申请退款金额大于可退款余额') !== false) {
|
||||
if ($this->isAlreadyRefunded($ext, $refundMoney)) {
|
||||
\think\Log::info('[shopro_huifu] 汇付检测到该笔退款此前已成功执行,执行本地状态幂等对齐: order_id=' . $order['id']);
|
||||
$refundLog->payment_json = json_encode(['idempotent' => true, 'remark' => '汇付核验已退款成功,自动对齐本地状态']);
|
||||
$refundLog->save();
|
||||
return true;
|
||||
}
|
||||
throw new \RuntimeException('汇付商户账户可用余额不足,请在汇付平台充值或留存可用资金后再退款');
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function isAlreadyRefunded($ext, $refundMoney)
|
||||
{
|
||||
try {
|
||||
SdkLoader::requireRequest('V2TradeAcctpaymentAcctlogQueryRequest.php');
|
||||
$request = new \BsPaySdk\request\V2TradeAcctpaymentAcctlogQueryRequest();
|
||||
$request->setReqSeqId(date('YmdHis') . mt_rand(1000, 9999));
|
||||
$request->setHuifuId($this->config->get('huifu_id'));
|
||||
$request->setAcctDate($ext['req_date'] ?? date('Ymd'));
|
||||
$request->setExtendInfo([
|
||||
'page_size' => '20',
|
||||
'page_num' => '1',
|
||||
'acct_type' => '01',
|
||||
]);
|
||||
|
||||
$result = $this->client->postRequest($request);
|
||||
$data = $this->client->parseResponse($result);
|
||||
if (empty($data['acct_log_list'])) {
|
||||
return false;
|
||||
}
|
||||
$list = is_string($data['acct_log_list']) ? json_decode($data['acct_log_list'], true) : $data['acct_log_list'];
|
||||
if (!is_array($list)) {
|
||||
return false;
|
||||
}
|
||||
$targetAmt = number_format((float)$refundMoney, 2, '.', '');
|
||||
foreach ($list as $log) {
|
||||
// 2006 为支付退款
|
||||
if (($log['acct_trans_type_code'] ?? '') === '2006' && ($log['trans_amt'] ?? '') === $targetAmt) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $ex) {
|
||||
\think\Log::error('[shopro_huifu] 幂等核查汇付退款流水异常: ' . $ex->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function scanpayRefund($ext, $refundLog, $refundMoney, $remark)
|
||||
|
||||
@@ -252,8 +252,8 @@ class Aftersale extends Backend
|
||||
if ($this->request->isAjax()) {
|
||||
$refund_money = round($this->request->post('refund_money', 0), 2);
|
||||
|
||||
if ($refund_money < 0) {
|
||||
$this->error('退款金额不能小于 0');
|
||||
if ($refund_money <= 0) {
|
||||
$this->error('退款金额必须大于 0');
|
||||
}
|
||||
|
||||
$aftersale = $this->model->withTrashed()->canOper()->where('id', $id)->find();
|
||||
@@ -268,37 +268,34 @@ class Aftersale extends Backend
|
||||
$this->error('订单不存在');
|
||||
}
|
||||
|
||||
$items = $order->item;
|
||||
$items = array_column($items, null, 'id');
|
||||
|
||||
// 旧的退款逻辑
|
||||
// 当前订单已退款总金额
|
||||
// $refunded_money = array_sum(array_column($items, 'refund_fee'));
|
||||
// 剩余可退款金额
|
||||
// $refund_surplus_money = $order->pay_fee - $refunded_money;
|
||||
// 如果退款金额大于订单支付总金额
|
||||
// if ($refund_money > $refund_surplus_money) {
|
||||
// $this->error('退款总金额不能大于实际支付金额');
|
||||
// }
|
||||
|
||||
// 新的退款逻辑
|
||||
$orderItem = $items[$aftersale['order_item_id']];
|
||||
// 剩余可退款金额
|
||||
$refund_surplus_money = bcsub($orderItem->pay_price, $orderItem->refund_fee, 2);
|
||||
|
||||
if ($refund_money > $refund_surplus_money) {
|
||||
$this->error('退款金额不能大于' . $refund_surplus_money . '元');
|
||||
}
|
||||
|
||||
if (!$orderItem || in_array($orderItem['refund_status'], [
|
||||
\app\admin\model\shopro\order\OrderItem::REFUND_STATUS_OK,
|
||||
\app\admin\model\shopro\order\OrderItem::REFUND_STATUS_FINISH,
|
||||
])) {
|
||||
$this->error('订单商品已退款,不能重复退款');
|
||||
$lockKey = 'lock:refund:aftersale:' . $id;
|
||||
if (\think\Cache::get($lockKey)) {
|
||||
$this->error('退款正在处理中,请勿重复操作');
|
||||
}
|
||||
\think\Cache::set($lockKey, 1, 10);
|
||||
|
||||
try {
|
||||
Db::transaction(function () use ($aftersale, $order, $orderItem, $refund_money, $refund_surplus_money) {
|
||||
Db::transaction(function () use ($id, $refund_money, $order) {
|
||||
// 行级排他锁锁定售后单与订单商品,防止并发重复退款
|
||||
$aftersale = $this->model->withTrashed()->where('id', $id)->lock(true)->find();
|
||||
if (!$aftersale || $aftersale['refund_status'] == \app\admin\model\shopro\order\Aftersale::REFUND_STATUS_FINISH) {
|
||||
throw new \Exception('售后单已退款,不能重复退款');
|
||||
}
|
||||
|
||||
$orderItem = OrderItem::where('id', $aftersale['order_item_id'])->lock(true)->find();
|
||||
if (!$orderItem || in_array($orderItem['refund_status'], [
|
||||
\app\admin\model\shopro\order\OrderItem::REFUND_STATUS_OK,
|
||||
\app\admin\model\shopro\order\OrderItem::REFUND_STATUS_FINISH,
|
||||
])) {
|
||||
throw new \Exception('订单商品已退款,不能重复退款');
|
||||
}
|
||||
|
||||
// 实时剩余可退金额硬校验(防多退)
|
||||
$refund_surplus_money = bcsub($orderItem->pay_price, $orderItem->refund_fee, 2);
|
||||
if (bccomp($refund_money, $refund_surplus_money, 2) === 1) {
|
||||
throw new \Exception('退款金额不能大于' . $refund_surplus_money . '元');
|
||||
}
|
||||
|
||||
$data = ['aftersale' => $aftersale, 'order' => $order, 'item' => $orderItem];
|
||||
\think\Hook::listen('aftersale_finish_before', $data);
|
||||
|
||||
@@ -325,9 +322,11 @@ class Aftersale extends Backend
|
||||
\think\Hook::listen('aftersale_finish_after', $data);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
\think\Cache::rm($lockKey);
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
|
||||
\think\Cache::rm($lockKey);
|
||||
return $this->success('操作成功');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1034,45 +1034,90 @@ class Order extends Base
|
||||
}
|
||||
}
|
||||
|
||||
$lockKey = 'lock:refund:order:' . $id;
|
||||
if (\think\Cache::get($lockKey)) {
|
||||
$this->error('退款正在处理中,请勿重复操作');
|
||||
}
|
||||
\think\Cache::set($lockKey, 1, 10);
|
||||
|
||||
try {
|
||||
Db::transaction(function () use ($order, $items, $item_id, $refund_money, $refund_surplus_money) {
|
||||
Db::transaction(function () use ($id, $item_id, $refund_money) {
|
||||
// 行级排他锁锁定订单与商品项,防止并发重复退款与脏读
|
||||
$order = $this->model->where('id', $id)->lock(true)->find();
|
||||
if (!$order) {
|
||||
throw new \Exception('订单不存在');
|
||||
}
|
||||
|
||||
$lockedItems = \app\admin\model\shopro\order\OrderItem::where('order_id', $id)->lock(true)->select();
|
||||
$items = array_column($lockedItems, null, 'id');
|
||||
|
||||
// 实时高精度计算已退金额与剩余可退金额
|
||||
$refunded_money = array_sum(array_column($items, 'refund_fee'));
|
||||
$refund_surplus_money = bcsub($order->pay_fee, $refunded_money, 2);
|
||||
|
||||
if (bccomp($refund_money, $refund_surplus_money, 2) === 1) {
|
||||
throw new \Exception('退款金额不能大于剩余可退金额 ' . $refund_surplus_money . ' 元');
|
||||
}
|
||||
|
||||
if ($item_id) {
|
||||
// 单个商品退款
|
||||
$item = $items[$item_id];
|
||||
$item = $items[$item_id] ?? null;
|
||||
if (!$item || in_array($item['refund_status'], [
|
||||
\app\admin\model\shopro\order\OrderItem::REFUND_STATUS_OK,
|
||||
\app\admin\model\shopro\order\OrderItem::REFUND_STATUS_FINISH,
|
||||
])) {
|
||||
throw new \Exception('订单商品已退款,不能重复退款');
|
||||
}
|
||||
$itemSurplus = bcsub($item->pay_price, $item->refund_fee, 2);
|
||||
if (bccomp($refund_money, $itemSurplus, 2) === 1) {
|
||||
throw new \Exception('退款金额不能大于商品剩余可退金额 ' . $itemSurplus . ' 元');
|
||||
}
|
||||
\app\admin\model\shopro\order\Order::startRefund($order, $item, $refund_money, $this->auth->getUserInfo(), '管理员操作退款');
|
||||
} else {
|
||||
// 全部退款
|
||||
// 未退款 item 商品总金额
|
||||
// 全部退款:过滤掉已退款的商品
|
||||
$pendingItems = [];
|
||||
$goods_total_amount = 0;
|
||||
foreach ($items as $ke => $it) {
|
||||
$goods_total_amount += ($it['goods_price'] * $it['goods_num']);
|
||||
foreach ($items as $it) {
|
||||
if (!in_array($it['refund_status'], [
|
||||
\app\admin\model\shopro\order\OrderItem::REFUND_STATUS_OK,
|
||||
\app\admin\model\shopro\order\OrderItem::REFUND_STATUS_FINISH,
|
||||
])) {
|
||||
$pendingItems[] = $it;
|
||||
$goods_total_amount += ($it['goods_price'] * $it['goods_num']);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($pendingItems)) {
|
||||
throw new \Exception('订单商品已全部退款,不能重复退款');
|
||||
}
|
||||
|
||||
$current_refunded_money = 0;
|
||||
for ($i = 0; $i < count($items); $i++) {
|
||||
if ($i == (count($items) - 1)) {
|
||||
$totalCount = count($pendingItems);
|
||||
for ($i = 0; $i < $totalCount; $i++) {
|
||||
if ($i == ($totalCount - 1)) {
|
||||
// 最后一条,全部退完
|
||||
$current_refund_money = $refund_money - $current_refunded_money;
|
||||
$current_refund_money = bcsub($refund_money, $current_refunded_money, 2);
|
||||
} else {
|
||||
// 按比例计算当前 item 应退金额
|
||||
$current_refund_money = round($refund_money * (($items[$i]['goods_price'] * $it['goods_num']) / $goods_total_amount), 2);
|
||||
$current_refund_money = round($refund_money * (($pendingItems[$i]['goods_price'] * $pendingItems[$i]['goods_num']) / $goods_total_amount), 2);
|
||||
}
|
||||
if (($current_refunded_money + $current_refund_money) > $refund_money) {
|
||||
$current_refund_money = $refund_money - $current_refunded_money;
|
||||
if (bcadd($current_refunded_money, $current_refund_money, 2) > $refund_money) {
|
||||
$current_refund_money = bcsub($refund_money, $current_refunded_money, 2);
|
||||
}
|
||||
|
||||
if ($current_refund_money >= 0) { // 支付金额或者退款金额 为 0 也能退
|
||||
$current_refunded_money += $current_refund_money;
|
||||
|
||||
\app\admin\model\shopro\order\Order::startRefund($order, $items[$i], $current_refund_money, $this->auth->getUserInfo(), '管理员操作退款');
|
||||
if ($current_refund_money > 0) {
|
||||
$current_refunded_money = bcadd($current_refunded_money, $current_refund_money, 2);
|
||||
\app\admin\model\shopro\order\Order::startRefund($order, $pendingItems[$i], $current_refund_money, $this->auth->getUserInfo(), '管理员操作退款');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
\think\Cache::rm($lockKey);
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
|
||||
\think\Cache::rm($lockKey);
|
||||
$item_list = \app\admin\model\shopro\order\OrderItem::where(['order_id' => $id])->select();
|
||||
return $this->success('操作成功', null, $item_list);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* 订单与售后退款异常捕获单元测试 (TDD)
|
||||
* 订单与售后退款异常捕获与防重复退款单元测试 (TDD)
|
||||
*/
|
||||
|
||||
class OrderRefundExceptionCatchTest
|
||||
@@ -10,9 +10,12 @@ class OrderRefundExceptionCatchTest
|
||||
|
||||
public function run()
|
||||
{
|
||||
echo "=== 开始执行 退款控制器异常捕获单元测试 (TDD) ===\n";
|
||||
echo "=== 开始执行 退款控制器与防重复退款单元测试 (TDD) ===\n";
|
||||
$this->testAftersaleRefundCatchesThrowable();
|
||||
$this->testAftersaleRefundAntiDuplicateLockAndRowLock();
|
||||
$this->testOrderRefundCatchesThrowable();
|
||||
$this->testOrderRefundAntiDuplicateLockAndRowLock();
|
||||
$this->testHuifuRefundIdempotentHandling();
|
||||
|
||||
echo "\n测试结果: {$this->passed} 通过, {$this->failed} 失败\n";
|
||||
if ($this->failed > 0) {
|
||||
@@ -31,46 +34,76 @@ class OrderRefundExceptionCatchTest
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 Aftersale 控制器 refund 方法是否具备 try catch (\Throwable $e) 与 $this->error($e->getMessage())
|
||||
*/
|
||||
public function testAftersaleRefundCatchesThrowable()
|
||||
{
|
||||
$filePath = __DIR__ . '/../application/admin/controller/shopro/order/Aftersale.php';
|
||||
$this->assert(is_file($filePath), "Aftersale.php 控制器必须存在");
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
|
||||
// 提取 refund 方法体
|
||||
$startPos = strpos($content, 'function refund(');
|
||||
$this->assert($startPos !== false, "Aftersale.php 必须包含 refund 方法");
|
||||
|
||||
$nextFuncPos = strpos($content, 'public function ', $startPos + 20);
|
||||
$methodBody = substr($content, $startPos, $nextFuncPos - $startPos);
|
||||
|
||||
$hasTryCatch = preg_match('/try\s*\{\s*Db::transaction\(.+?\}\s*catch\s*\(\\\\?Throwable\s+\$e\)\s*\{\s*\$this->error\(\$e->getMessage\(\)\);\s*\}/s', $methodBody);
|
||||
$this->assert((bool)$hasTryCatch, "Aftersale.php 的 refund 方法必须使用 try...catch(\\Throwable \$e) 包裹 Db::transaction 并在失败时调用 \$this->error(\$e->getMessage())");
|
||||
$hasTryCatch = preg_match('/try\s*\{\s*Db::transaction\(.+?\}\s*catch\s*\(\\\\?Throwable\s+\$e\)\s*\{.+?\$this->error\(\$e->getMessage\(\)\);\s*\}/s', $methodBody);
|
||||
$this->assert((bool)$hasTryCatch, "Aftersale.php 的 refund 方法必须使用 try...catch(\\Throwable \$e) 包裹 Db::transaction");
|
||||
}
|
||||
|
||||
public function testAftersaleRefundAntiDuplicateLockAndRowLock()
|
||||
{
|
||||
$filePath = __DIR__ . '/../application/admin/controller/shopro/order/Aftersale.php';
|
||||
$content = file_get_contents($filePath);
|
||||
$startPos = strpos($content, 'function refund(');
|
||||
$nextFuncPos = strpos($content, 'public function ', $startPos + 20);
|
||||
$methodBody = substr($content, $startPos, $nextFuncPos - $startPos);
|
||||
|
||||
$hasCacheLock = strpos($methodBody, 'lock:refund:aftersale:') !== false;
|
||||
$this->assert($hasCacheLock, "Aftersale.php 的 refund 方法必须使用 Cache 锁防止重复点击/并发提交");
|
||||
|
||||
$hasRowLock = strpos($methodBody, 'lock(true)') !== false;
|
||||
$this->assert($hasRowLock, "Aftersale.php 的 refund 方法事务内必须使用 lock(true) 排他行锁防多退");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 Order 控制器 refund 方法是否具备 try catch (\Throwable $e) 与 $this->error($e->getMessage())
|
||||
*/
|
||||
public function testOrderRefundCatchesThrowable()
|
||||
{
|
||||
$filePath = __DIR__ . '/../application/admin/controller/shopro/order/Order.php';
|
||||
$this->assert(is_file($filePath), "Order.php 控制器必须存在");
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
|
||||
// 提取 refund 方法体
|
||||
$startPos = strpos($content, 'function refund(');
|
||||
$this->assert($startPos !== false, "Order.php 必须包含 refund 方法");
|
||||
|
||||
$nextFuncPos = strpos($content, 'public function ', $startPos + 20);
|
||||
$methodBody = substr($content, $startPos, $nextFuncPos - $startPos);
|
||||
|
||||
$hasTryCatch = preg_match('/try\s*\{\s*Db::transaction\(.+?\}\s*catch\s*\(\\\\?Throwable\s+\$e\)\s*\{\s*\$this->error\(\$e->getMessage\(\)\);\s*\}/s', $methodBody);
|
||||
$this->assert((bool)$hasTryCatch, "Order.php 的 refund 方法必须使用 try...catch(\\Throwable \$e) 包裹 Db::transaction 并在失败时调用 \$this->error(\$e->getMessage())");
|
||||
$hasTryCatch = preg_match('/try\s*\{\s*Db::transaction\(.+?\}\s*catch\s*\(\\\\?Throwable\s+\$e\)\s*\{.+?\$this->error\(\$e->getMessage\(\)\);\s*\}/s', $methodBody);
|
||||
$this->assert((bool)$hasTryCatch, "Order.php 的 refund 方法必须使用 try...catch(\\Throwable \$e) 包裹 Db::transaction");
|
||||
}
|
||||
|
||||
public function testOrderRefundAntiDuplicateLockAndRowLock()
|
||||
{
|
||||
$filePath = __DIR__ . '/../application/admin/controller/shopro/order/Order.php';
|
||||
$content = file_get_contents($filePath);
|
||||
$startPos = strpos($content, 'function refund(');
|
||||
$nextFuncPos = strpos($content, 'public function ', $startPos + 20);
|
||||
$methodBody = substr($content, $startPos, $nextFuncPos - $startPos);
|
||||
|
||||
$hasCacheLock = strpos($methodBody, 'lock:refund:order:') !== false;
|
||||
$this->assert($hasCacheLock, "Order.php 的 refund 方法必须使用 Cache 锁防止重复点击/并发提交");
|
||||
|
||||
$hasRowLock = strpos($methodBody, 'lock(true)') !== false;
|
||||
$this->assert($hasRowLock, "Order.php 的 refund 方法事务内必须使用 lock(true) 排他行锁防多退");
|
||||
}
|
||||
|
||||
public function testHuifuRefundIdempotentHandling()
|
||||
{
|
||||
$filePath = __DIR__ . '/../addons/shopro_huifu/library/HuifuRefundService.php';
|
||||
$this->assert(is_file($filePath), "HuifuRefundService.php 必须存在");
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
$hasIdempotent = strpos($content, '23000003') !== false || strpos($content, 'isAlreadyRefunded') !== false;
|
||||
$this->assert($hasIdempotent, "HuifuRefundService 必须包含针对汇付已退款的幂等核查与对齐机制");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户