From deb981a88562df28d377193c1174f1890a18d638 Mon Sep 17 00:00:00 2001 From: wikiboss Date: Sat, 12 Sep 2026 18:37:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(pay):=20=E4=BF=AE=E5=A4=8D=E5=8C=BA?= =?UTF-8?q?=E5=9F=9F=E4=BB=A3=E7=90=86=E5=88=86=E4=BD=A3=E8=84=8F=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=AF=BC=E8=87=B4=E6=94=AF=E4=BB=98=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=E4=B8=8E=E8=AE=A2=E5=8D=95=E6=9C=AA=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E9=97=AE=E9=A2=98=E5=B9=B6=E5=A2=9E=E5=8A=A0=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E6=94=AF=E4=BB=98=E9=9A=94=E7=A6=BB=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../library/regional_agent/Commission.php | 7 + .../shopro/library/regional_agent/Reward.php | 20 ++- .../library/traits/model/order/OrderOper.php | 32 +++- .../library/HuifuNotifyHandler.php | 9 +- tests/PaymentProcessRobustnessTest.php | 140 ++++++++++++++++++ 5 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 tests/PaymentProcessRobustnessTest.php diff --git a/addons/shopro/library/regional_agent/Commission.php b/addons/shopro/library/regional_agent/Commission.php index b7d9d49..55151e5 100644 --- a/addons/shopro/library/regional_agent/Commission.php +++ b/addons/shopro/library/regional_agent/Commission.php @@ -3,6 +3,7 @@ namespace addons\shopro\library\regional_agent; use addons\shopro\model\Order as OrderModel; +use addons\shopro\model\User as UserModel; use addons\shopro\model\regional_agent\Agent as AgentModel; use addons\shopro\model\regional_agent\Level as LevelModel; use addons\shopro\model\regional_agent\Order as RegionalOrderModel; @@ -86,6 +87,12 @@ class Commission } $agent = AgentModel::findByArea($areaId, $areaLevel); if ($agent) { + // 校验代理绑定的用户是否存在,防止脏数据导致分佣结算崩溃 + $agentUser = UserModel::get($agent['user_id']); + if (!$agentUser) { + \think\Log::error("[regional_agent] agent id={$agent['id']} user_id={$agent['user_id']} not found in fa_user, skipped"); + continue; + } $matched[] = [ 'agent' => $agent, 'area_id' => $areaId, diff --git a/addons/shopro/library/regional_agent/Reward.php b/addons/shopro/library/regional_agent/Reward.php index 1b0d707..64b60f7 100644 --- a/addons/shopro/library/regional_agent/Reward.php +++ b/addons/shopro/library/regional_agent/Reward.php @@ -49,13 +49,23 @@ class Reward return false; } - $agent->setInc('total_income', $reward['commission']); - UserModel::money($reward['commission'], $reward['agent_user_id'], 'regional_agent_income', $reward['id'], '区域公司差价', $reward); + $agentUser = UserModel::get($reward['agent_user_id']); + if (!$agentUser) { + \think\Log::error("[regional_agent] settleReward fail: agent_user_id={$reward['agent_user_id']} not found in fa_user"); + return false; + } - $reward->status = self::STATUS_ACCOUNTED; - $reward->save(); + try { + $agent->setInc('total_income', $reward['commission']); + UserModel::money($reward['commission'], $reward['agent_user_id'], 'regional_agent_income', $reward['id'], '区域公司差价', $reward); - return true; + $reward->status = self::STATUS_ACCOUNTED; + $reward->save(); + return true; + } catch (\Throwable $e) { + \think\Log::error("[regional_agent] settleReward money error: " . $e->getMessage()); + return false; + } } public function backByRegionalOrder($regionalOrderId, $event) diff --git a/addons/shopro/library/traits/model/order/OrderOper.php b/addons/shopro/library/traits/model/order/OrderOper.php index 59875bb..18d3077 100644 --- a/addons/shopro/library/traits/model/order/OrderOper.php +++ b/addons/shopro/library/traits/model/order/OrderOper.php @@ -838,14 +838,22 @@ trait OrderOper $order->save(); - saveUserMessage([ - 'user_id' => $order->id, - 'content' => "付款成功,订单号:{$order->order_sn}", - 'create_time' => time() - ]); + try { + saveUserMessage([ + 'user_id' => $order->user_id, + 'content' => "付款成功,订单号:{$order->order_sn}", + 'create_time' => time() + ]); + } catch (\Throwable $e) { + \think\Log::error('[order_pay] saveUserMessage failed: ' . $e->getMessage()); + } $user = User::where('id', $order->user_id)->find(); - OrderAction::operAdd($order, null, $user, 'user', '用户支付成功'); + try { + OrderAction::operAdd($order, null, $user, 'user', '用户支付成功'); + } catch (\Throwable $e) { + \think\Log::error('[order_pay] operAdd failed: ' . $e->getMessage()); + } // 组合支付扣除用户余额 if ($order->pay_type == 'group') { @@ -862,10 +870,18 @@ trait OrderOper } $data = ['order' => $order]; - \think\Hook::listen('order_payed_after', $data); + try { + \think\Hook::listen('order_payed_after', $data); + } catch (\Throwable $e) { + \think\Log::error('[order_pay] order_payed_after hook failed: ' . $e->getMessage()); + } // 支付成功后续使用异步队列处理 - \think\Queue::push('\addons\shopro\job\OrderPayed@payed', ['order' => $order, 'user' => $user], 'shopro-high'); + try { + \think\Queue::push('\addons\shopro\job\OrderPayed@payed', ['order' => $order, 'user' => $user], 'shopro-high'); + } catch (\Throwable $e) { + \think\Log::error('[order_pay] push OrderPayed job failed: ' . $e->getMessage()); + } return $order; } diff --git a/addons/shopro_huifu/library/HuifuNotifyHandler.php b/addons/shopro_huifu/library/HuifuNotifyHandler.php index 512ecdf..b3fb29d 100644 --- a/addons/shopro_huifu/library/HuifuNotifyHandler.php +++ b/addons/shopro_huifu/library/HuifuNotifyHandler.php @@ -133,7 +133,14 @@ class HuifuNotifyHandler $order->paymentProcess($order, $notify); }); } catch (\Throwable $e) { - Log::error('[shopro_huifu] notify process fail: ' . $e->getMessage()); + $msg = trim($e->getMessage()); + if ($e instanceof \think\exception\HttpResponseException) { + $resp = $e->getResponse(); + $msg = 'HttpResponseException: ' . json_encode($resp ? $resp->getData() : null, JSON_UNESCAPED_UNICODE); + } elseif ($msg === '') { + $msg = get_class($e) . ' @ ' . basename($e->getFile()) . ':' . $e->getLine(); + } + Log::error('[shopro_huifu] notify process fail: ' . $msg); return $this->ack($reqSeqId, false); } diff --git a/tests/PaymentProcessRobustnessTest.php b/tests/PaymentProcessRobustnessTest.php new file mode 100644 index 0000000..b62e787 --- /dev/null +++ b/tests/PaymentProcessRobustnessTest.php @@ -0,0 +1,140 @@ +testRewardSettleValidatesUserAndCatchesException(); + $this->testCommissionMatchFiltersInvalidUserAgent(); + $this->testOrderOperCorrectsSaveUserMessageUserId(); + $this->testOrderOperIsolatesSideEffectsFromCorePayment(); + $this->testHuifuNotifyHandlerExtractsHttpResponseException(); + + echo "\n测试结果: {$this->passed} 通过, {$this->failed} 失败\n"; + if ($this->failed > 0) { + exit(1); + } + } + + private function assert($condition, $message) + { + if ($condition) { + echo " [PASS] {$message}\n"; + $this->passed++; + } else { + echo " [FAIL] {$message}\n"; + $this->failed++; + } + } + + /** + * 测试 1:Reward::settleReward 必须校验代理用户是否存在,且发放佣金必须有 try-catch 隔离保护 + */ + public function testRewardSettleValidatesUserAndCatchesException() + { + $filePath = __DIR__ . '/../addons/shopro/library/regional_agent/Reward.php'; + $this->assert(is_file($filePath), "Reward.php 文件必须存在"); + + $content = file_get_contents($filePath); + $startPos = strpos($content, 'function settleReward('); + $this->assert($startPos !== false, "Reward.php 必须包含 settleReward 方法"); + + $endPos = strpos($content, 'public function ', $startPos + 20); + $methodBody = $endPos !== false ? substr($content, $startPos, $endPos - $startPos) : substr($content, $startPos); + + // 检查是否有对用户存在的校验 + $hasUserCheck = (strpos($methodBody, 'UserModel::get') !== false || strpos($methodBody, 'UserModel::where') !== false || strpos($methodBody, '$agent->user') !== false); + $this->assert($hasUserCheck, "settleReward 必须校验代理绑定的用户是否存在"); + + // 检查是否有 try-catch 保护 UserModel::money + $hasTryCatch = preg_match('/try\s*\{[^}]*UserModel::money.+?\}\s*catch\s*\(\\\\?Throwable\s+\$e\)/s', $methodBody); + $this->assert((bool)$hasTryCatch, "settleReward 必须使用 try-catch(\\Throwable \$e) 隔离佣金发放异常"); + } + + /** + * 测试 2:Commission::matchRegionalAgents 必须过滤绑定的用户不存在的无效代理 + */ + public function testCommissionMatchFiltersInvalidUserAgent() + { + $filePath = __DIR__ . '/../addons/shopro/library/regional_agent/Commission.php'; + $content = file_get_contents($filePath); + $startPos = strpos($content, 'function matchRegionalAgents('); + $this->assert($startPos !== false, "Commission.php 必须包含 matchRegionalAgents 方法"); + + $endPos = strpos($content, 'function isGoodsRegionalEnabled(', $startPos + 20); + $methodBody = $endPos !== false ? substr($content, $startPos, $endPos - $startPos) : substr($content, $startPos); + + $hasCheck = (strpos($methodBody, 'user') !== false && (strpos($methodBody, 'UserModel::get') !== false || strpos($methodBody, '$agent->user') !== false)); + $this->assert($hasCheck, "matchRegionalAgents 必须校验代理的 user 是否存在有效"); + } + + /** + * 测试 3:OrderOper::paymentProcess 必须修复 saveUserMessage 的 user_id 为 $order->user_id + */ + public function testOrderOperCorrectsSaveUserMessageUserId() + { + $filePath = __DIR__ . '/../addons/shopro/library/traits/model/order/OrderOper.php'; + $content = file_get_contents($filePath); + $startPos = strpos($content, 'function paymentProcess('); + $this->assert($startPos !== false, "OrderOper.php 必须包含 paymentProcess 方法"); + + $endPos = strpos($content, 'public function refundProcess(', $startPos + 20); + if ($endPos === false) { + $endPos = strpos($content, 'function refundProcess(', $startPos + 20); + } + $methodBody = $endPos !== false ? substr($content, $startPos, $endPos - $startPos) : substr($content, $startPos); + + $hasBug = strpos($methodBody, "'user_id' => \$order->id") !== false; + $this->assert(!$hasBug, "paymentProcess 不能将 \$order->id 赋值给 user_id (应使用 \$order->user_id)"); + + $hasFix = strpos($methodBody, "'user_id' => \$order->user_id") !== false; + $this->assert($hasFix, "paymentProcess 必须使用 \$order->user_id 保存用户消息"); + } + + /** + * 测试 4:OrderOper::paymentProcess 必须对下游副作用(发消息、操作日志、order_payed_after 钩子)进行独立隔离保护 + */ + public function testOrderOperIsolatesSideEffectsFromCorePayment() + { + $filePath = __DIR__ . '/../addons/shopro/library/traits/model/order/OrderOper.php'; + $content = file_get_contents($filePath); + $startPos = strpos($content, 'function paymentProcess('); + $endPos = strpos($content, 'function refundProcess(', $startPos + 20); + if ($endPos === false) { + $endPos = strpos($content, 'public function refundProcess(', $startPos + 20); + } + $methodBody = $endPos !== false ? substr($content, $startPos, $endPos - $startPos) : substr($content, $startPos); + + // 验证 $order->save() 在钩子和发信之前已经执行(核心状态先落地) + $savePos = strpos($methodBody, '$order->save()'); + $hookPos = strpos($methodBody, "Hook::listen('order_payed_after'"); + $this->assert($savePos !== false && $hookPos !== false && $savePos < $hookPos, "核心订单状态必须在执行 order_payed_after 钩子前保存"); + + // 验证 Hook::listen('order_payed_after') 必须处于 try-catch 保护下 + $hasHookCatch = preg_match('/try\s*\{[^}]*Hook::listen\(\'order_payed_after\'.+?\}\s*catch\s*\(\\\\?Throwable\s+\$e\)/s', $methodBody); + $this->assert((bool)$hasHookCatch, "Hook::listen('order_payed_after') 必须在 try-catch(\\Throwable \$e) 保护下执行,不能回滚已付款订单"); + } + + /** + * 测试 5:HuifuNotifyHandler::handlePayNotify 遇 HttpResponseException 需解析响应信息记录完整日志 + */ + public function testHuifuNotifyHandlerExtractsHttpResponseException() + { + $filePath = __DIR__ . '/../addons/shopro_huifu/library/HuifuNotifyHandler.php'; + $content = file_get_contents($filePath); + + $hasExtraction = strpos($content, 'HttpResponseException') !== false && strpos($content, 'getResponse') !== false; + $this->assert($hasExtraction, "HuifuNotifyHandler 必须捕获并解析 HttpResponseException 响应内容,避免空白错误日志"); + } +} + +if (php_sapi_name() === 'cli' && basename(__FILE__) === basename($_SERVER['SCRIPT_FILENAME'] ?? '')) { + (new PaymentProcessRobustnessTest())->run(); +}