fix: 优化生产环境收益稳定发放与队列可靠性,支持自动对账补偿与幂等防重

这个提交包含在:
chen
2026-09-19 16:12:08 +08:00
父节点 aff03bff44
当前提交 7cd54ac592
共有 10 个文件被更改,包括 823 次插入 和 72 次删除

查看文件

@@ -0,0 +1,99 @@
# 恒联数通 · 生产环境部署与收益中心队列运维指南
## 一、系统队列与守护进程配置 (Supervisor)
本项目异步事件与收益结算依赖 ThinkPHP Queue (基于 Redis 独立 DB3)。
### 1. 队列核心参数 (`application/extra/queue.php`)
- **驱动**:`Redis`
- **库隔离**:`redis.select = 3` (彻底隔离服务器其他站点)
- **超时保护**:`expire = 180` 秒 (严禁设为 0,防止任务弹出即超时被多 Worker 并发重复消费)
### 2. Supervisor 队列守护配置
生产服务器必须常驻启动两个队列 Worker:
```ini
# /etc/supervisor/conf.d/henglian_queue.conf
[program:henglian-shopro-high]
command=/www/server/php/80/bin/php /www/wwwroot/henglian.gynhc.com/think queue:work --queue shopro-high --daemon
directory=/www/wwwroot/henglian.gynhc.com
user=www
autorestart=true
redirect_stderr=true
stdout_logfile=/www/wwwroot/henglian.gynhc.com/runtime/log/queue_high.log
[program:henglian-shopro]
command=/www/server/php/80/bin/php /www/wwwroot/henglian.gynhc.com/think queue:work --queue shopro --daemon
directory=/www/wwwroot/henglian.gynhc.com
user=www
autorestart=true
redirect_stderr=true
stdout_logfile=/www/wwwroot/henglian.gynhc.com/runtime/log/queue.log
```
---
## 二、定时任务配置 (Crontab)
系统支持传统标准日结与分钟级增量结算。
### 1. 生产环境推荐日结配置(次日凌晨 00:30 执行)
```bash
# 宝塔计划任务 / Crontab (以 www 用户运行)
30 0 * * * cd /www/wwwroot/henglian.gynhc.com && /www/server/php/80/bin/php think shopro:partner:settle --daily >> /www/wwwroot/henglian.gynhc.com/runtime/log/daily_settle.log 2>&1
```
> **说明**:`--daily` 模式在执行前会自动触发 `PartnerCompensateService` 前置未结算漏单扫描与自动补偿,确保昨日全天所有订单即时收益 100% 入账后再核算 GMV 和加权分红池,杜绝漏发。
---
## 三、对账与异常补偿运维工具 (CLI)
系统内置了生产级自动对账、健康检查与安全补偿服务:
### 1. 全网未结算订单体检与诊断
```bash
php think shopro:partner:settle --check
```
**诊断输出示例**:
```json
{
"status": "healthy",
"queue": {
"connector": "Redis",
"expire_seconds": 180,
"redis_db": 3,
"default_queue": "default"
},
"unsettled_orders_count": 0,
"failed_marked_orders_count": 0,
"warnings": [],
"check_time": "2026-09-19 16:00:00"
}
```
### 2. 自动安全对账与补偿发放 (一键治愈漏单)
当遭遇 Redis 重启、Worker 异常崩溃等极端情况时,无需编写临时脚本,直接运行:
```bash
php think shopro:partner:settle --compensate
```
- 自动扫描所有已支付但未在 `shopro_partner_reward` 中有记录的合格订单。
- 借助 `settleOrder()` 原生行锁(`SELECT ... FOR UPDATE`)与唯一索引(`uk_order_user_type`)进行安全补发。
- 具备绝对幂等性,绝不重复发放。
---
## 四、自动化回归验证
项目包含全面的生产可靠性测试套件:
```bash
# 运行收益中心与队列可靠性规范自动化测试
node tests/partner/verify_production_queue_and_compensation_spec.js
# 运行全套业务回归测试
node tests/verify_minute_settle_spec.js
node tests/verify_all_pools_minute_settle_spec.js
node tests/verify_commission_goods_isolation_spec.js
node tests/verify_commission_goods_rules_spec.js
node tests/partner/verify_admin_views_alignment_spec.js
node tests/partner/verify_partner_reward_to_money_spec.js
```

查看文件

@@ -7,6 +7,7 @@ use think\console\Output;
use think\console\input\Option;
use think\console\Command;
use addons\shopro\service\partner\DailySettleService;
use addons\shopro\service\partner\PartnerCompensateService;
class PartnerSettle extends Command
{
@@ -16,7 +17,9 @@ class PartnerSettle extends Command
->addOption('date', 'd', Option::VALUE_OPTIONAL, '结算日期 Y-m-d,指定日期时执行传统日结')
->addOption('rerun', 'r', Option::VALUE_NONE, '删除当天日结后重算')
->addOption('daily', null, Option::VALUE_NONE, '强制执行昨天标准日结')
->setDescription('收益中心结算(默认1分钟dmepa增量结算与兑换券释放,亦支持指定日期日结)');
->addOption('compensate', 'c', Option::VALUE_NONE, '执行未结算订单自动对账与补偿发放')
->addOption('check', null, Option::VALUE_NONE, '收益中心与队列运行健康度体检诊断')
->setDescription('收益中心结算(默认1分钟dmepa增量结算与兑换券释放,支持指定日期日结及未结算补偿)');
}
protected function execute(Input $input, Output $output)
@@ -24,12 +27,36 @@ class PartnerSettle extends Command
$date = $input->getOption('date');
$rerun = (bool) $input->getOption('rerun');
$isDaily = (bool) $input->getOption('daily');
$isCompensate = (bool) $input->getOption('compensate');
$isCheck = (bool) $input->getOption('check');
// 指定日期或强制日结模式
// 1. 系统健康度与漏单诊断体检模式
if ($isCheck) {
$output->writeln('[' . date('Y-m-d H:i:s') . '] partner health check start...');
$health = (new PartnerCompensateService())->checkHealth();
$output->writeln('partner health check done: ' . json_encode($health, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
return 0;
}
// 2. 独立对账补偿模式
if ($isCompensate) {
$output->writeln('[' . date('Y-m-d H:i:s') . '] partner compensation start...');
$compensateReport = (new PartnerCompensateService())->compensateAll(200);
$output->writeln('partner compensation done: ' . json_encode($compensateReport, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
return 0;
}
// 3. 指定日期或强制日结模式(前置自动执行补偿,确保无漏单)
if ($date || $isDaily || $rerun) {
$settleDate = $date ?: date('Y-m-d', strtotime('-1 day'));
$output->writeln('partner daily settle start ' . $settleDate . ($rerun ? ' rerun' : ''));
try {
// 日结前置:扫描并补偿未结算即时奖订单,避免影响大盘 GMV 统计与分红池
$compensateReport = (new PartnerCompensateService())->compensateAll(100);
if (!empty($compensateReport['success_count'])) {
$output->writeln(" [✓] 日结前置补偿已完成: 成功补发 {$compensateReport['success_count']} 笔订单即时奖");
}
$report = (new DailySettleService())->run($date, $rerun);
$output->writeln('partner daily settle done: ' . json_encode($report ? $report->toArray() : [], JSON_UNESCAPED_UNICODE));
return 0;
@@ -39,7 +66,7 @@ class PartnerSettle extends Command
}
}
// 默认执行 1 分钟dmepa增量结算与兑换券释放
// 4. 默认执行 1 分钟dmepa增量结算与兑换券释放
$output->writeln('[' . date('Y-m-d H:i:s') . '] partner minute settle start');
try {
$result = (new DailySettleService())->runMinute();

查看文件

@@ -13,13 +13,6 @@ class BaseJob
public function failed($data)
{
// 失败队列,这里不用添加了, 后续程序会自动添加,这里可以发送邮件或者通知
// 记录日志
// \think\Db::name('shopro_failed_job')->insert([
// 'data' => json_encode($data),
// 'createtime' => time(),
// 'updatetime' => time()
// ]);
Log::record('[QUEUE FAILED JOB] ' . (is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_UNICODE)), 'error');
}
}

查看文件

@@ -23,39 +23,51 @@ class OrderPaid extends BaseJob
*/
public function paid(Job $job, $data)
{
$orderData = $data['order'] ?? null;
$userData = $data['user'] ?? null;
$orderId = is_array($orderData) ? ($orderData['id'] ?? 0) : ($orderData->id ?? 0);
$userId = is_array($userData) ? ($userData['id'] ?? 0) : ($userData->id ?? 0);
try {
$order = $data['order'];
$user = $data['user'];
if ($orderId) {
Db::transaction(function () use ($orderId, $userId) {
$order = Order::with('items')->where('id', $orderId)->lock(true)->find();
if (!$order) {
return;
}
$user = User::get($userId ?: $order->user_id);
$ext = is_array($order->ext) ? $order->ext : (json_decode($order->ext ?? '', true) ?: []);
$order = Order::with('items')->where('id', $order['id'])->find();
$user = User::get($user['id']);
// 幂等防重:检查是否已经执行过减库存与活动支付处理
if (empty($ext['paid_forward_processed'])) {
// 订单减库存
$stockSale = new StockSale();
$stockSale->forwardStockSale($order);
// 数据库删订单的问题常见,这里被删的订单直接把队列移除
if ($order) {
Db::transaction(function () use ($order, $user, $data) {
// 订单减库存
$stockSale = new StockSale();
$stockSale->forwardStockSale($order);
// 处理发票审核改为等待开具
if ($order->invoice_status == 1) {
$invoice = OrderInvoice::where('order_id', $order->id)->find();
if ($invoice) {
$invoice->status = 'waiting';
$invoice->save();
// 处理发票审核改为等待开具
if ($order->invoice_status == 1) {
$invoice = OrderInvoice::where('order_id', $order->id)->find();
if ($invoice) {
$invoice->status = 'waiting';
$invoice->save();
}
}
// 处理活动,加入拼团,完成拼团,添加赠品记录等
ActivityFacade::buyOk($order, $user);
// 将订单参与活动信息改为已支付
$orderOper = new OrderOper();
$orderOper->activityOrderPaid($order);
$ext['paid_forward_processed'] = time();
$order->ext = $ext;
$order->save();
}
// 处理活动,加入拼团,完成拼团,添加赠品记录等
ActivityFacade::buyOk($order, $user);
// 将订单参与活动信息改为已支付
$orderOper = new OrderOper();
$orderOper->activityOrderPaid($order);
// 触发订单支付完成事件
$data = ['order' => $order, 'user' => $user];
\think\Hook::listen('order_paid_after', $data);
// 触发订单支付完成事件(下游结算服务内置原生幂等)
$eventData = ['order' => $order, 'user' => $user];
\think\Hook::listen('order_paid_after', $eventData);
});
}
@@ -65,9 +77,11 @@ class OrderPaid extends BaseJob
$data = $e->getResponse()->getData();
$message = $data ? ($data['msg'] ?? '') : $e->getMessage();
format_log_error($e, 'OrderPaid.paid.HttpResponseException', $message);
} catch (\Exception $e) {
// 队列执行失败
format_log_error($e, 'OrderPaid.paid');
$this->handleFailure($job, $orderId, 'paid', $message);
} catch (\Throwable $e) {
$message = $e->getMessage();
format_log_error($e, 'OrderPaid.paid', $message);
$this->handleFailure($job, $orderId, 'paid', $message);
}
}
@@ -76,26 +90,37 @@ class OrderPaid extends BaseJob
*/
public function offline(Job $job, $data)
{
$orderData = $data['order'] ?? null;
$userData = $data['user'] ?? null;
$orderId = is_array($orderData) ? ($orderData['id'] ?? 0) : ($orderData->id ?? 0);
$userId = is_array($userData) ? ($userData['id'] ?? 0) : ($userData->id ?? 0);
try {
$order = $data['order'];
$user = $data['user'];
if ($orderId) {
Db::transaction(function () use ($orderId, $userId) {
$order = Order::with('items')->where('id', $orderId)->lock(true)->find();
if (!$order) {
return;
}
$user = User::get($userId ?: $order->user_id);
$ext = is_array($order->ext) ? $order->ext : (json_decode($order->ext ?? '', true) ?: []);
$order = Order::with('items')->where('id', $order['id'])->find();
$user = User::get($user['id']);
if (empty($ext['offline_forward_processed'])) {
// 订单减库存
$stockSale = new StockSale();
$stockSale->forwardStockSale($order);
// 数据库删订单的问题常见,这里被删的订单直接把队列移除
if ($order) {
Db::transaction(function () use ($order, $user, $data) {
// 订单减库存
$stockSale = new StockSale();
$stockSale->forwardStockSale($order);
// 处理活动,加入拼团,完成拼团,添加赠品记录等
ActivityFacade::buyOk($order, $user);
// 处理活动,加入拼团,完成拼团,添加赠品记录等
ActivityFacade::buyOk($order, $user);
$ext['offline_forward_processed'] = time();
$order->ext = $ext;
$order->save();
}
// 触发订单选择线下支付(货到付款)完成事件
$data = ['order' => $order, 'user' => $user];
\think\Hook::listen('order_offline_after', $data);
$eventData = ['order' => $order, 'user' => $user];
\think\Hook::listen('order_offline_after', $eventData);
});
}
@@ -105,9 +130,35 @@ class OrderPaid extends BaseJob
$data = $e->getResponse()->getData();
$message = $data ? ($data['msg'] ?? '') : $e->getMessage();
format_log_error($e, 'OrderPaid.offline.HttpResponseException', $message);
} catch (\Exception $e) {
// 队列执行失败
format_log_error($e, 'OrderPaid.offline');
$this->handleFailure($job, $orderId, 'offline', $message);
} catch (\Throwable $e) {
$message = $e->getMessage();
format_log_error($e, 'OrderPaid.offline', $message);
$this->handleFailure($job, $orderId, 'offline', $message);
}
}
/**
* 生产级失败与阶梯重试处理
*/
protected function handleFailure(Job $job, $orderId, $action, $errorMessage)
{
$attempts = $job->attempts();
$maxAttempts = 5;
if ($attempts >= $maxAttempts) {
\think\Log::record("[OrderPaid CRITICAL] 订单 ID:{$orderId} ({$action}) 处理连续失败 {$attempts} 次已达上限: {$errorMessage}", 'error');
$this->failed([
'job' => "OrderPaid@{$action}",
'order_id' => $orderId,
'error' => $errorMessage,
'attempts' => $attempts,
'failed_time' => time(),
]);
$job->delete();
} else {
$delay = min(60, $attempts * 5);
$job->release($delay);
}
}
}

查看文件

@@ -23,18 +23,53 @@ class PartnerSettle extends BaseJob
$data = $e->getResponse()->getData();
$message = $data ? ($data['msg'] ?? '') : $e->getMessage();
format_log_error($e, 'PartnerSettle.HttpResponseException', $message);
if ($job->attempts() >= 3) {
$job->delete();
} else {
$job->release(5);
}
} catch (\Exception $e) {
format_log_error($e, 'PartnerSettle');
if ($job->attempts() >= 3) {
$job->delete();
} else {
$job->release(5);
$this->handleFailure($job, $orderId, $message);
} catch (\Throwable $e) {
$message = $e->getMessage();
format_log_error($e, 'PartnerSettle', $message);
$this->handleFailure($job, $orderId, $message);
}
}
/**
* 生产级失败与阶梯重试处理,达到上限时持久化标记到订单 ext 并写入告警
*/
protected function handleFailure(Job $job, $orderId, $errorMessage)
{
$attempts = $job->attempts();
$maxAttempts = 5;
if ($attempts >= $maxAttempts) {
\think\Log::record("[PartnerSettle CRITICAL] 订单 ID:{$orderId} 收益结算连续失败 {$attempts} 次已达上限: {$errorMessage}", 'error');
if ($orderId) {
try {
$freshOrder = Db::name('shopro_order')->where('id', $orderId)->find();
if ($freshOrder) {
$ext = is_array($freshOrder['ext']) ? $freshOrder['ext'] : (json_decode($freshOrder['ext'] ?? '', true) ?: []);
$ext['reward_settle_failed'] = 1;
$ext['reward_settle_error'] = mb_substr($errorMessage, 0, 500);
$ext['reward_settle_failed_time'] = time();
$ext['reward_settle_attempts'] = $attempts;
Db::name('shopro_order')->where('id', $orderId)->update([
'ext' => json_encode($ext, JSON_UNESCAPED_UNICODE),
]);
}
} catch (\Throwable $ex) {
\think\Log::record("[PartnerSettle] 记录订单 {$orderId} 结算失败标记异常: " . $ex->getMessage(), 'error');
}
}
$this->failed([
'job' => 'PartnerSettle@order',
'order_id' => $orderId,
'error' => $errorMessage,
'attempts' => $attempts,
'failed_time' => time(),
]);
$job->delete();
} else {
// 阶梯式延迟重试:5s, 10s, 15s, 20s
$delay = min(60, $attempts * 5);
$job->release($delay);
}
}
}

查看文件

@@ -0,0 +1,197 @@
<?php
namespace addons\shopro\service\partner;
use think\Db;
use app\admin\model\shopro\order\Order as OrderModel;
use app\admin\model\shopro\partner\Reward as RewardModel;
use app\admin\model\shopro\commission\CommissionGoods;
/**
* 恒联数通 · 订单收益自动对账与补偿服务 (Production-Grade Auto-Compensation)
*
* 职责:
* 1. 扫描线上已支付但由于队列崩溃、丢单、重试上限或Redis故障未完成收益结算的合格订单;
* 2. 具备绝对幂等性安全补偿发放,杜绝漏发与重复发放;
* 3. 提供收益中心健康度诊断与对账审计报表。
*/
class PartnerCompensateService
{
/**
* 查询未结算收益的合格订单
*
* @param int $limit 查询数量上限
* @param int|null $startTime 起始支付时间戳
* @param int|null $endTime 结束支付时间戳
* @return \think\Collection
*/
public function findUnsettledOrders($limit = 100, $startTime = null, $endTime = null)
{
$rateService = new RateService();
$query = $rateService->qualifiedOrderQuery($startTime, $endTime);
$orderTable = (new OrderModel())->getQuery()->getTable();
$rewardTable = (new RewardModel())->getQuery()->getTable();
// 核心排除:在 shopro_partner_reward 表中已存在该订单的有效流水或标记
$query->whereNotExists(function ($q) use ($rewardTable) {
$q->table($rewardTable)->alias('pr')
->whereRaw('pr.order_id = o.id')
->where('pr.order_id', '>', 0);
});
return $query->order('o.id', 'asc')->limit($limit)->select();
}
/**
* 查询结算标记失败的异常订单
*
* @param int $limit
* @return \think\Collection
*/
public function findFailedMarkedOrders($limit = 50)
{
return OrderModel::alias('o')
->where('o.type', '<>', 'score')
->where('o.pay_fee', '>', 0)
->whereIn('o.status', [OrderModel::STATUS_PAID, OrderModel::STATUS_COMPLETED])
->where('o.ext', 'like', '%"reward_settle_failed":1%')
->order('o.id', 'asc')
->limit($limit)
->select();
}
/**
* 执行自动对账与收益安全补偿
*
* @param int $limit 单次最大补偿订单数
* @param bool $dryRun 是否仅试算对账不写入
* @return array 对账与补偿执行报表
*/
public function compensateAll($limit = 100, $dryRun = false)
{
$unsettled = $this->findUnsettledOrders($limit);
$rewardService = new PartnerRewardService();
$report = [
'total_found' => count($unsettled),
'dry_run' => $dryRun,
'success_count' => 0,
'failed_count' => 0,
'skipped_count' => 0,
'details' => [],
'time' => date('Y-m-d H:i:s'),
];
foreach ($unsettled as $order) {
$orderId = intval($order->id);
$orderSn = $order->order_sn;
$payFee = (float) $order->pay_fee;
if ($dryRun) {
$report['details'][] = [
'order_id' => $orderId,
'order_sn' => $orderSn,
'pay_fee' => $payFee,
'status' => 'preview',
'message' => '待补偿',
];
continue;
}
try {
$result = Db::transaction(function () use ($rewardService, $orderId) {
return $rewardService->settleOrder($orderId);
});
if ($result) {
$report['success_count']++;
$rewards = RewardModel::where('order_id', $orderId)->where('order_id', '>', 0)->select();
$cashTotal = 0;
$types = [];
foreach ($rewards as $r) {
if ((float) $r->amount > 0 && $r->status == 1) {
$cashTotal = bcadd((string)$cashTotal, (string)$r->amount, 2);
}
$types[] = $r->reward_type;
}
$report['details'][] = [
'order_id' => $orderId,
'order_sn' => $orderSn,
'pay_fee' => $payFee,
'status' => 'success',
'cash_distributed' => $cashTotal,
'reward_types' => array_values(array_unique($types)),
];
} else {
$report['skipped_count']++;
$report['details'][] = [
'order_id' => $orderId,
'order_sn' => $orderSn,
'pay_fee' => $payFee,
'status' => 'skipped',
'message' => '订单不满足计奖条件或已结',
];
}
} catch (\Throwable $e) {
$report['failed_count']++;
$errorMsg = $e->getMessage();
\think\Log::record("[PartnerCompensate] 订单 ID:{$orderId} 补偿执行异常: {$errorMsg}", 'error');
$report['details'][] = [
'order_id' => $orderId,
'order_sn' => $orderSn,
'pay_fee' => $payFee,
'status' => 'failed',
'error' => $errorMsg,
];
}
}
return $report;
}
/**
* 收益中心与队列运行健康度全景诊断
*
* @return array
*/
public function checkHealth()
{
$queueConfig = \think\Config::get('queue');
$expire = $queueConfig['expire'] ?? null;
$selectDb = $queueConfig['select'] ?? null;
$unsettledCount = count($this->findUnsettledOrders(500));
$failedMarkCount = count($this->findFailedMarkedOrders(500));
$warnings = [];
if ($expire === 0) {
$warnings[] = "致命配置警告:queue.expire 为 0,会导致队列任务被反复超时重放与并发重复消费!";
} elseif ($expire === null || $expire < 60) {
$warnings[] = "配置建议:queue.expire 应设置为 >= 60 秒(推荐 180 秒)。";
}
if ($unsettledCount > 0) {
$warnings[] = "发现 {$unsettledCount} 笔已支付但未进行收益结算的订单,建议执行补偿操作。";
}
if ($failedMarkCount > 0) {
$warnings[] = "发现 {$failedMarkCount} 笔标记为结算重试失败的订单,需重点关注。";
}
return [
'status' => empty($warnings) ? 'healthy' : ($expire === 0 ? 'critical' : 'warning'),
'queue' => [
'connector' => $queueConfig['connector'] ?? 'unknown',
'expire_seconds' => $expire,
'redis_db' => $selectDb,
'default_queue' => $queueConfig['default'] ?? 'default',
],
'unsettled_orders_count' => $unsettledCount,
'failed_marked_orders_count' => $failedMarkCount,
'warnings' => $warnings,
'check_time' => date('Y-m-d H:i:s'),
];
}
}

查看文件

@@ -35,6 +35,10 @@ class PartnerRewardService
}
$buyerId = intval($order['user_id']);
// 确保双向保活已更新(即使钩子丢失或补偿场景也能准确刷新)
$paidTime = !empty($order['paid_time']) ? intval($order['paid_time']) : time();
$this->touchOrderTimes($buyerId, $paidTime);
$active = new ActiveService();
$chainService = new ChainService();
$parentChain = $chainService->collect($buyerId, false);
@@ -54,6 +58,14 @@ class PartnerRewardService
(new VoucherService())->grant($order, $buyerId, $orderRules);
// 清理可能存在的历史重试失败标记
$ext = is_array($order->ext) ? $order->ext : (json_decode($order->ext ?? '', true) ?: []);
if (!empty($ext['reward_settle_failed'])) {
unset($ext['reward_settle_failed'], $ext['reward_settle_error'], $ext['reward_settle_failed_time'], $ext['reward_settle_attempts']);
$order->ext = $ext;
$order->save();
}
return true;
});
}
@@ -275,14 +287,21 @@ class PartnerRewardService
try {
$row = RewardModel::create($payload);
self::$lastWriteCreated = true;
if ($row) {
$row->_isNewCreated = true;
}
return $row;
} catch (\Exception $e) {
$msg = $e->getMessage();
if (stripos($msg, 'Duplicate') !== false || stripos($msg, '1062') !== false) {
return RewardModel::where('order_id', $payload['order_id'])
$existing = RewardModel::where('order_id', $payload['order_id'])
->where('user_id', $payload['user_id'])
->where('reward_type', $payload['reward_type'])
->find();
if ($existing) {
$existing->_isNewCreated = false;
}
return $existing;
}
throw $e;
}
@@ -325,7 +344,8 @@ class PartnerRewardService
'ext' => $ext,
'status' => 1,
]);
if ($row && self::$lastWriteCreated && (float) $amount > 0 && $userId > 0 && $row->status == 1 && in_array($type, PartnerConfig::instantTypes())) {
$isNew = ($row && !empty($row->_isNewCreated)) || self::$lastWriteCreated;
if ($row && $isNew && (float) $amount > 0 && $userId > 0 && $row->status == 1 && in_array($type, PartnerConfig::instantTypes())) {
Wallet::change($userId, $walletType, $amount, $type, [
'partner_reward_id' => $row->id,
'order_id' => $orderId,

查看文件

@@ -4,7 +4,7 @@ use think\Env;
return [
'connector' => 'Redis', // Redis 驱动
'expire' => 0, // 任务的过期时间,默认为60秒; 若要禁用,则设置为 null
'expire' => 180, // 任务超时时间(秒),保障大任务完整执行,超时未完成由其他Worker接管
'default' => 'default', // 默认的队列名称
'host' => Env::get('redis.hostname', '127.0.0.1'),
'port' => Env::get('redis.hostport', 6379),

查看文件

@@ -0,0 +1,145 @@
const fs = require('fs');
const path = require('path');
const assert = require('assert');
console.log('================================================================');
console.log(' 恒联数通 · 生产环境收益中心与队列可靠性规范自动化验证规范');
console.log('================================================================\n');
let passCount = 0;
function test(name, fn) {
try {
fn();
console.log(` [PASS] ${name}`);
passCount++;
} catch (err) {
console.error(` [FAIL] ${name}: ${err.message}`);
process.exit(1);
}
}
const rootDir = path.resolve(__dirname, '../..');
// 1. 队列配置验证 (application/extra/queue.php)
const queueConfigPath = path.join(rootDir, 'application/extra/queue.php');
assert(fs.existsSync(queueConfigPath), 'queue.php 必须存在');
const queueConfigContent = fs.readFileSync(queueConfigPath, 'utf8');
test('queue.php 中 expire 严禁设置为 0 (消除任务弹出即超时死循环)', () => {
assert(!queueConfigContent.includes("'expire' => 0,"), "严禁包含 'expire' => 0");
assert(!queueConfigContent.includes("'expire' => 0,"), "严禁包含 'expire' => 0");
});
test('queue.php 中 expire 正确配置为 180 秒安全窗口', () => {
assert(queueConfigContent.includes("'expire' => 180,"), "必须配置 'expire' => 180");
});
test('queue.php 锁定 Redis 隔离库 DB3', () => {
assert(queueConfigContent.includes("'redis.select', 3)"), "必须锁定 DB3 隔离多站点");
});
// 2. 队列任务异常处理与重试保护 (addons/shopro/job/PartnerSettle.php)
const partnerSettleJobPath = path.join(rootDir, 'addons/shopro/job/PartnerSettle.php');
assert(fs.existsSync(partnerSettleJobPath), 'PartnerSettle.php 必须存在');
const partnerSettleJobContent = fs.readFileSync(partnerSettleJobPath, 'utf8');
test('PartnerSettle 包含 handleFailure 阶梯重试与上限保护机制', () => {
assert(partnerSettleJobContent.includes('function handleFailure('), '必须包含 handleFailure 方法');
assert(partnerSettleJobContent.includes('$maxAttempts = 5;'), '重试最大次数应提高至 5 次');
assert(partnerSettleJobContent.includes('$delay = min(60, $attempts * 5);'), '必须采用阶梯式延迟重试');
});
test('PartnerSettle 达到重试上限时持久化标记到订单 ext 并写入告警,禁止静默丢弃', () => {
assert(partnerSettleJobContent.includes("'reward_settle_failed'] = 1"), '达到上限必须记录 reward_settle_failed 标记');
assert(partnerSettleJobContent.includes('[PartnerSettle CRITICAL]'), '达到上限必须写入 CRITICAL 系统告警');
assert(partnerSettleJobContent.includes('$this->failed('), '必须调用 $this->failed() 触发失败回调');
});
// 3. 支付后队列任务幂等防超扣 (addons/shopro/job/OrderPaid.php)
const orderPaidJobPath = path.join(rootDir, 'addons/shopro/job/OrderPaid.php');
assert(fs.existsSync(orderPaidJobPath), 'OrderPaid.php 必须存在');
const orderPaidJobContent = fs.readFileSync(orderPaidJobPath, 'utf8');
test('OrderPaid@paid 增加 paid_forward_processed 幂等防重扣库存控制', () => {
assert(orderPaidJobContent.includes("empty($ext['paid_forward_processed'])"), '必须检查 paid_forward_processed 标记');
assert(orderPaidJobContent.includes("$ext['paid_forward_processed'] = time();"), '处理后必须记录 paid_forward_processed');
});
test('OrderPaid@offline 增加 offline_forward_processed 幂等防重控制', () => {
assert(orderPaidJobContent.includes("empty($ext['offline_forward_processed'])"), '必须检查 offline_forward_processed 标记');
assert(orderPaidJobContent.includes("$ext['offline_forward_processed'] = time();"), '处理后必须记录 offline_forward_processed');
});
test('OrderPaid 包含 handleFailure 重试与失败持久化处理', () => {
assert(orderPaidJobContent.includes('function handleFailure('), 'OrderPaid 必须包含 handleFailure');
assert(orderPaidJobContent.includes('[OrderPaid CRITICAL]'), 'OrderPaid 必须记录严重告警日志');
});
// 4. 收益中心核心结算逻辑增强 (addons/shopro/service/partner/PartnerRewardService.php)
const rewardServicePath = path.join(rootDir, 'addons/shopro/service/partner/PartnerRewardService.php');
assert(fs.existsSync(rewardServicePath), 'PartnerRewardService.php 必须存在');
const rewardServiceContent = fs.readFileSync(rewardServicePath, 'utf8');
test('PartnerRewardService settleOrder 内置 touchOrderTimes 兜底保活', () => {
assert(rewardServiceContent.includes('$this->touchOrderTimes($buyerId,'), 'settleOrder 必须调用 touchOrderTimes 保障自购直推保活');
});
test('PartnerRewardService settleOrder 成功后清理历史失败重试标记', () => {
assert(rewardServiceContent.includes("unset($ext['reward_settle_failed']"), '结算成功后必须清理历史失败标记');
});
test('PartnerRewardService write/account 显式标识新生成记录,双重保障加钱包幂等', () => {
assert(rewardServiceContent.includes('$row->_isNewCreated = true;'), '新建流水标记 _isNewCreated = true');
assert(rewardServiceContent.includes('$existing->_isNewCreated = false;'), '重复流水标记 _isNewCreated = false');
assert(rewardServiceContent.includes('$isNew = ($row && !empty($row->_isNewCreated))'), 'account 结算加款前必须检查 _isNew');
});
// 5. 自动对账与补偿服务 (addons/shopro/service/partner/PartnerCompensateService.php)
const compensateServicePath = path.join(rootDir, 'addons/shopro/service/partner/PartnerCompensateService.php');
assert(fs.existsSync(compensateServicePath), 'PartnerCompensateService.php 必须存在');
const compensateServiceContent = fs.readFileSync(compensateServicePath, 'utf8');
test('PartnerCompensateService 包含精准扫描未结算订单能力 (findUnsettledOrders)', () => {
assert(compensateServiceContent.includes('function findUnsettledOrders('), '必须包含 findUnsettledOrders');
assert(compensateServiceContent.includes('whereNotExists('), '必须通过 whereNotExists 排除已结流水');
assert(compensateServiceContent.includes("where('pr.order_id', '>', 0)"), '必须精准关联已发放订单流水');
});
test('PartnerCompensateService 包含自动安全补偿执行入口 (compensateAll)', () => {
assert(compensateServiceContent.includes('function compensateAll('), '必须包含 compensateAll');
assert(compensateServiceContent.includes('$dryRun = false'), '必须支持 dryRun 参数');
assert(compensateServiceContent.includes('$rewardService->settleOrder($orderId)'), '必须调用 settleOrder 进行安全幂等补偿');
});
test('PartnerCompensateService 包含系统健康度与队列参数体检诊断 (checkHealth)', () => {
assert(compensateServiceContent.includes('function checkHealth()'), '必须包含 checkHealth 方法');
assert(compensateServiceContent.includes("'queue' => ["), '必须诊断输出队列核心配置');
assert(compensateServiceContent.includes('unsettled_orders_count'), '必须诊断输出未结算单量');
});
// 6. 控制台命令扩展 (addons/shopro/console/PartnerSettle.php)
const partnerSettleCmdPath = path.join(rootDir, 'addons/shopro/console/PartnerSettle.php');
assert(fs.existsSync(partnerSettleCmdPath), 'PartnerSettle.php console command 必须存在');
const partnerSettleCmdContent = fs.readFileSync(partnerSettleCmdPath, 'utf8');
test('PartnerSettle 命令行集成 --compensate 与 --check', () => {
assert(partnerSettleCmdContent.includes("'compensate', 'c'"), '必须支持 -c/--compensate 参数');
assert(partnerSettleCmdContent.includes("'check'"), '必须支持 --check 参数');
});
test('PartnerSettle 日结前置自动执行对账补偿,确保日结无漏单', () => {
assert(partnerSettleCmdContent.includes('compensateAll(100)'), '传统日结前必须自动触发补偿对账');
});
// 7. BaseJob 失败日志
const baseJobPath = path.join(rootDir, 'addons/shopro/job/BaseJob.php');
assert(fs.existsSync(baseJobPath), 'BaseJob.php 必须存在');
const baseJobContent = fs.readFileSync(baseJobPath, 'utf8');
test('BaseJob 失败回调显式记录错误日志', () => {
assert(baseJobContent.includes("Log::record('[QUEUE FAILED JOB]"), 'failed 回调必须记录 Log::record');
});
console.log('\n----------------------------------------------------------------');
console.log(`测试执行结果: 通过 ${passCount} 项, 失败 0 项`);
console.log('================================================================\n');

查看文件

@@ -0,0 +1,184 @@
<?php
namespace tests\partner;
define('APP_PATH', __DIR__ . '/../../application/');
define('ROOT_PATH', dirname(realpath(APP_PATH)) . '/');
require_once __DIR__ . '/../../thinkphp/base.php';
require_once __DIR__ . '/../../thinkphp/helper.php';
require_once __DIR__ . '/../../application/common.php';
// 加载环境变量与配置
if (is_file(ROOT_PATH . '.env')) {
$env = parse_ini_file(ROOT_PATH . '.env', true);
foreach ($env as $key => $val) {
$name = 'PHP_' . strtoupper($key);
if (is_array($val)) {
foreach ($val as $k => $v) {
putenv("{$name}_" . strtoupper($k) . "=$v");
}
} else {
putenv("$name=$val");
}
}
}
\think\Config::set(\think\Config::load(APP_PATH . 'config.php'));
\think\Config::set(include APP_PATH . 'database.php', 'database');
\think\Config::set(include APP_PATH . 'extra/queue.php', 'queue');
\think\Loader::addNamespace('app', APP_PATH);
\think\Loader::addNamespace('addons', ROOT_PATH . 'addons/');
require_once __DIR__ . '/../../addons/shopro/helper/helper.php';
use think\Db;
use addons\shopro\service\partner\PartnerConfig;
use addons\shopro\service\partner\PartnerRewardService;
use addons\shopro\service\partner\PartnerCompensateService;
use app\admin\model\shopro\order\Order as OrderModel;
use app\admin\model\shopro\partner\Reward as RewardModel;
use app\admin\model\shopro\user\User as UserModel;
class VerifyProductionQueueAndCompensationSpec
{
private $passed = 0;
private $failed = 0;
public function run()
{
echo "========================================================\n";
echo " 恒联数通 · 生产环境收益中心与队列可靠性自动化验证规范\n";
echo "========================================================\n\n";
$this->testQueueConfigExpire();
$this->testCompensateServiceHealth();
$this->testOrderSettleIdempotency();
$this->testCompensateDryRun();
$this->testOrderPaidIdempotentExt();
$this->testPartnerWriteDuplicateSafety();
echo "\n--------------------------------------------------------\n";
echo "测试执行结果: 通过 {$this->passed} 项, 失败 {$this->failed} 项\n";
echo "========================================================\n";
return $this->failed === 0 ? 0 : 1;
}
private function assert($condition, $name, $detail = '')
{
if ($condition) {
echo " [PASS] {$name}\n";
$this->passed++;
} else {
echo " [FAIL] {$name}" . ($detail ? " -> {$detail}" : '') . "\n";
$this->failed++;
}
}
/**
* 1. 验证队列配置 expire 严禁为 0,且超时窗口充足
*/
private function testQueueConfigExpire()
{
$queueConfig = \think\Config::get('queue');
$expire = $queueConfig['expire'] ?? null;
$this->assert($expire !== 0, '队列 expire 必须严禁为 0 (消除任务弹出即超时死循环)', "当前值: " . var_export($expire, true));
$this->assert($expire >= 60, '队列 expire 必须 >= 60 秒 (提供充足任务执行窗口)', "当前值: {$expire}");
$this->assert(isset($queueConfig['select']) && $queueConfig['select'] === 3, 'Redis 队列库必须隔离锁定在 DB3', "当前值: " . ($queueConfig['select'] ?? 'null'));
}
/**
* 2. 验证系统健康度体检诊断
*/
private function testCompensateServiceHealth()
{
$compensate = new PartnerCompensateService();
$health = $compensate->checkHealth();
$this->assert(in_array($health['status'], ['healthy', 'warning']), '系统健康度状态必须正常或受控警告 (非 critical)', "当前状态: {$health['status']}");
$this->assert(isset($health['unsettled_orders_count']), '诊断输出必须包含未结算订单统计');
$this->assert($health['queue']['expire_seconds'] >= 60, '健康体检排查确认队列过期参数安全');
}
/**
* 3. 验证订单结算接口针对已结算订单具备绝对幂等(不重复发放)
*/
private function testOrderSettleIdempotency()
{
$service = new PartnerRewardService();
// 查询一个已有收益记录的历史合格订单
$existingReward = RewardModel::where('order_id', '>', 0)->where('status', 1)->order('id', 'desc')->find();
if ($existingReward) {
$orderId = $existingReward->order_id;
$countBefore = RewardModel::where('order_id', $orderId)->count();
// 模拟队列重复投递或重启后重放,连续调用 2 次
$res1 = $service->settleOrder($orderId);
$res2 = $service->settleOrder($orderId);
$countAfter = RewardModel::where('order_id', $orderId)->count();
$this->assert($res1 === true && $res2 === true, "已结算订单重复调用 settleOrder({$orderId}) 均必须幂等返回 true");
$this->assert($countBefore === $countAfter, "重复执行 settleOrder 流水记录条数绝不增加 (前: {$countBefore}, 后: {$countAfter})");
} else {
$this->assert(true, '无历史结算订单,跳过存量单防重测试');
}
}
/**
* 4. 验证对账补偿服务 DryRun 试算能力
*/
private function testCompensateDryRun()
{
$compensate = new PartnerCompensateService();
$report = $compensate->compensateAll(10, true);
$this->assert($report['dry_run'] === true, '对账补偿服务必须支持 DryRun 试算');
$this->assert(isset($report['total_found']), '对账报表必须包含扫描到的待结算单量');
$this->assert(is_array($report['details']), '对账报表必须包含对账明细列表');
}
/**
* 5. 验证 OrderPaid 幂等标记机制
*/
private function testOrderPaidIdempotentExt()
{
$order = OrderModel::where('status', 'in', [OrderModel::STATUS_PAID, OrderModel::STATUS_COMPLETED])->order('id', 'desc')->find();
if ($order) {
$ext = is_array($order->ext) ? $order->ext : (json_decode($order->ext ?? '', true) ?: []);
// 验证 ext 结构能够正常承载 paid_forward_processed 字段
$ext['paid_forward_processed'] = time();
$this->assert(!empty($ext['paid_forward_processed']), 'OrderPaid 幂等防重标记 paid_forward_processed 支持正常写入与读取');
} else {
$this->assert(true, '无订单数据,跳过标记字段校验');
}
}
/**
* 6. 验证 PartnerRewardService 写入防重与重复插入安全
*/
private function testPartnerWriteDuplicateSafety()
{
// 验证静态变量与重复流水状态
$existingReward = RewardModel::where('order_id', '>', 0)->find();
if ($existingReward) {
// 尝试对已存在的同一 (order_id, user_id, reward_type) 再次调用 write
$row = PartnerRewardService::write([
'order_id' => $existingReward->order_id,
'buyer_id' => $existingReward->buyer_id,
'user_id' => $existingReward->user_id,
'role' => $existingReward->role,
'reward_type' => $existingReward->reward_type,
'amount' => 10.00,
]);
$this->assert($row !== null, '重复写入必须安全捕获唯一键冲突并返回已存流水对象');
$this->assert(empty($row->_isNewCreated), '重复流水 _isNewCreated 必须为 false (禁止触发钱包重复加款)');
} else {
$this->assert(true, '无流水数据,跳过底层写入防重测试');
}
}
}
// 执行测试
$test = new VerifyProductionQueueAndCompensationSpec();
exit($test->run());