feat: 完善支付通道和收款监听链路

新增 ChannelNotifyPayloadInterface 等支付插件通知契约,规范 pay_no 定位和插件返回校验。

新增微信、支付宝、收钱吧、Postar 个人收款插件适配,支持余额识别与备注识别。

新增 receipt-watcher 后端进程、Redis 队列 job 和平台事件监听,覆盖收款流水通知、商户通知、退款派发、转账派发与清算完成。

补齐个人收款监听相关系统配置、仓储、服务费冻结明细、订单后台操作和通道测试能力。

重构支付单创建、回调、费用、风控、结算和通道统计链路,统一状态流转与幂等处理。
This commit is contained in:
技术老胡
2026-05-11 16:28:48 +08:00
parent 0e5de50337
commit fd1f53f2ee
136 changed files with 14416 additions and 3992 deletions

View File

@@ -3,6 +3,8 @@
namespace app\service\ops\stat;
use app\common\base\BaseService;
use app\model\payment\PayOrder;
use app\model\payment\RefundOrder;
use app\model\admin\ChannelDailyStat;
use app\repository\ops\stat\ChannelDailyStatRepository;
@@ -93,6 +95,68 @@ class ChannelDailyStatService extends BaseService
return $row ?: null;
}
/**
* 记录支付成功统计。
*
* @param PayOrder $payOrder 支付单
* @return void
*/
public function recordPaySuccess(PayOrder $payOrder): void
{
$this->applyDelta($this->buildBaseDelta($payOrder) + [
'pay_success_count' => 1,
'pay_fail_count' => 0,
'pay_amount' => (int) $payOrder->pay_amount,
'refund_count' => 0,
'refund_amount' => 0,
'latency_ms' => $this->resolvePayLatencyMs($payOrder),
]);
}
/**
* 记录支付失败类统计。
*
* @param PayOrder $payOrder 支付单
* @return void
*/
public function recordPayFailure(PayOrder $payOrder): void
{
$this->applyDelta($this->buildBaseDelta($payOrder) + [
'pay_success_count' => 0,
'pay_fail_count' => 1,
'pay_amount' => 0,
'refund_count' => 0,
'refund_amount' => 0,
'latency_ms' => 0,
]);
}
/**
* 记录退款成功统计。
*
* @param RefundOrder $refundOrder 退款单
* @return void
*/
public function recordRefundSuccess(RefundOrder $refundOrder): void
{
if ((int) $refundOrder->channel_id <= 0) {
return;
}
$this->applyDelta([
'merchant_id' => (int) $refundOrder->merchant_id,
'merchant_group_id' => (int) $refundOrder->merchant_group_id,
'channel_id' => (int) $refundOrder->channel_id,
'stat_date' => $this->resolveDate($refundOrder->succeeded_at ?: $refundOrder->updated_at ?: $refundOrder->created_at),
'pay_success_count' => 0,
'pay_fail_count' => 0,
'pay_amount' => 0,
'refund_count' => 1,
'refund_amount' => (int) $refundOrder->refund_amount,
'latency_ms' => 0,
]);
}
/**
* 格式化单条统计记录。
*
@@ -112,6 +176,128 @@ class ChannelDailyStatService extends BaseService
return $row;
}
/**
* 构建支付单统计基础维度。
*
* @param PayOrder $payOrder 支付单
* @return array<string, mixed> 统计维度
*/
private function buildBaseDelta(PayOrder $payOrder): array
{
return [
'merchant_id' => (int) $payOrder->merchant_id,
'merchant_group_id' => (int) $payOrder->merchant_group_id,
'channel_id' => (int) $payOrder->channel_id,
'stat_date' => $this->resolveDate($payOrder->paid_at ?: $payOrder->failed_at ?: $payOrder->closed_at ?: $payOrder->timeout_at ?: $payOrder->updated_at ?: $payOrder->created_at),
];
}
/**
* 应用统计增量。
*
* @param array<string, mixed> $delta 统计增量
* @return void
*/
private function applyDelta(array $delta): void
{
$channelId = (int) ($delta['channel_id'] ?? 0);
if ($channelId <= 0) {
return;
}
$this->transactionRetry(function () use ($delta, $channelId): void {
$statDate = (string) ($delta['stat_date'] ?? date('Y-m-d'));
$row = $this->channelDailyStatRepository->findForUpdateByChannelAndDate($channelId, $statDate);
if (!$row) {
$row = $this->channelDailyStatRepository->create([
'merchant_id' => (int) ($delta['merchant_id'] ?? 0),
'merchant_group_id' => (int) ($delta['merchant_group_id'] ?? 0),
'channel_id' => $channelId,
'stat_date' => $statDate,
'pay_success_count' => 0,
'pay_fail_count' => 0,
'pay_amount' => 0,
'refund_count' => 0,
'refund_amount' => 0,
'avg_latency_ms' => 0,
'success_rate_bp' => 0,
'health_score' => 0,
]);
$row = $this->channelDailyStatRepository->findForUpdateByChannelAndDate($channelId, $statDate) ?: $row;
}
$previousSuccess = (int) $row->pay_success_count;
$successDelta = (int) ($delta['pay_success_count'] ?? 0);
$latencyMs = (int) ($delta['latency_ms'] ?? 0);
$row->merchant_id = (int) ($row->merchant_id ?: ($delta['merchant_id'] ?? 0));
$row->merchant_group_id = (int) ($row->merchant_group_id ?: ($delta['merchant_group_id'] ?? 0));
$row->pay_success_count = $previousSuccess + $successDelta;
$row->pay_fail_count = (int) $row->pay_fail_count + (int) ($delta['pay_fail_count'] ?? 0);
$row->pay_amount = (int) $row->pay_amount + (int) ($delta['pay_amount'] ?? 0);
$row->refund_count = (int) $row->refund_count + (int) ($delta['refund_count'] ?? 0);
$row->refund_amount = (int) $row->refund_amount + (int) ($delta['refund_amount'] ?? 0);
if ($successDelta > 0 && $latencyMs > 0) {
$row->avg_latency_ms = $previousSuccess > 0
? (int) floor(((int) $row->avg_latency_ms * $previousSuccess + $latencyMs) / max(1, $previousSuccess + $successDelta))
: $latencyMs;
}
$this->refreshQualityFields($row);
$row->save();
});
}
/**
* 刷新成功率和健康分。
*
* 成功率以万分比保存,健康分以成功率百分制为基础,并按平均耗时做轻量扣分。
*
* @param ChannelDailyStat $row 统计记录
* @return void
*/
private function refreshQualityFields(ChannelDailyStat $row): void
{
$successCount = (int) $row->pay_success_count;
$failCount = (int) $row->pay_fail_count;
$total = $successCount + $failCount;
$row->success_rate_bp = $total > 0 ? (int) floor($successCount * 10000 / $total) : 0;
$latencyPenalty = min(30, (int) floor((int) $row->avg_latency_ms / 1000));
$row->health_score = $total > 0 ? max(0, min(100, (int) floor($row->success_rate_bp / 100) - $latencyPenalty)) : 0;
}
/**
* 计算支付成功耗时。
*
* @param PayOrder $payOrder 支付单
* @return int 耗时毫秒数
*/
private function resolvePayLatencyMs(PayOrder $payOrder): int
{
$start = strtotime((string) ($payOrder->request_at ?: $payOrder->created_at));
$end = strtotime((string) ($payOrder->paid_at ?: $payOrder->updated_at));
if (!$start || !$end || $end < $start) {
return 0;
}
return (int) (($end - $start) * 1000);
}
/**
* 解析统计日期。
*
* @param mixed $value 时间值
* @return string 日期,格式 Y-m-d
*/
private function resolveDate(mixed $value): string
{
$timestamp = strtotime((string) $value);
return $timestamp ? date('Y-m-d', $timestamp) : date('Y-m-d');
}
/**
* 构建基础查询。
*
@@ -151,6 +337,3 @@ class ChannelDailyStatService extends BaseService
}