fix(pay): 修复区域代理分佣脏数据导致支付事务回滚与订单未付款问题并增加核心支付隔离保护

这个提交包含在:
wikiboss
2026-09-12 18:37:01 +08:00
父节点 65290f5742
当前提交 deb981a885
共有 5 个文件被更改,包括 194 次插入 和 14 次删除

查看文件

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

查看文件

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

查看文件

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

查看文件

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

查看文件

@@ -0,0 +1,140 @@
<?php
/**
* 订单支付流程与区域代理分佣健壮性单元测试 (TDD)
*/
class PaymentProcessRobustnessTest
{
private $passed = 0;
private $failed = 0;
public function run()
{
echo "=== 开始执行 订单支付与区域代理健壮性单元测试 (TDD) ===\n";
$this->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();
}