From 2b996b8a2e51a8feff89e8b35b01989e03420273 Mon Sep 17 00:00:00 2001 From: wikiboss Date: Sat, 12 Sep 2026 17:18:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E9=80=80=E6=AC=BE?= =?UTF-8?q?=E9=98=B2=E9=87=8D=E4=B8=8E=E9=98=B2=E5=A4=9A=E9=80=80=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=EF=BC=8C=E5=A2=9E=E5=8A=A0=E9=98=B2=E9=87=8D=E9=94=81?= =?UTF-8?q?=E3=80=81=E4=BA=8B=E5=8A=A1=E6=8E=92=E4=BB=96=E8=A1=8C=E9=94=81?= =?UTF-8?q?=E5=8F=8A=E6=B1=87=E4=BB=98=E5=B9=82=E7=AD=89=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../library/HuifuRefundService.php | 52 ++++++++++++- .../controller/shopro/order/Aftersale.php | 59 +++++++------- .../admin/controller/shopro/order/Order.php | 77 +++++++++++++++---- tests/OrderRefundExceptionCatchTest.php | 65 ++++++++++++---- 4 files changed, 190 insertions(+), 63 deletions(-) diff --git a/addons/shopro_huifu/library/HuifuRefundService.php b/addons/shopro_huifu/library/HuifuRefundService.php index c92f667..e5dfece 100644 --- a/addons/shopro_huifu/library/HuifuRefundService.php +++ b/addons/shopro_huifu/library/HuifuRefundService.php @@ -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) diff --git a/application/admin/controller/shopro/order/Aftersale.php b/application/admin/controller/shopro/order/Aftersale.php index 6a085e5..9bec17b 100644 --- a/application/admin/controller/shopro/order/Aftersale.php +++ b/application/admin/controller/shopro/order/Aftersale.php @@ -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('操作成功'); } } diff --git a/application/admin/controller/shopro/order/Order.php b/application/admin/controller/shopro/order/Order.php index e00a05f..4341a13 100644 --- a/application/admin/controller/shopro/order/Order.php +++ b/application/admin/controller/shopro/order/Order.php @@ -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); } diff --git a/tests/OrderRefundExceptionCatchTest.php b/tests/OrderRefundExceptionCatchTest.php index f8d45ea..2423cf6 100644 --- a/tests/OrderRefundExceptionCatchTest.php +++ b/tests/OrderRefundExceptionCatchTest.php @@ -1,6 +1,6 @@ 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 必须包含针对汇付已退款的幂等核查与对齐机制"); } }