mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-05-10 02:44:27 +08:00
1. 维护代码健壮
2. 更新项目结构文档
This commit is contained in:
525
app/service/payment/runtime/MerchantNotifyDispatcherService.php
Normal file
525
app/service/payment/runtime/MerchantNotifyDispatcherService.php
Normal file
@@ -0,0 +1,525 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\common\constant\EventConstant;
|
||||
use app\common\constant\NotifyConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\payment\BizOrder;
|
||||
use app\model\payment\NotifyTask;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\model\payment\RefundOrder;
|
||||
use app\model\payment\SettlementOrder;
|
||||
use app\repository\merchant\credential\MerchantApiCredentialRepository;
|
||||
use app\repository\payment\notify\NotifyTaskRepository;
|
||||
use app\repository\payment\trade\BizOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\service\payment\config\PaymentTypeService;
|
||||
use app\service\payment\epay\EpaySignerManager;
|
||||
use GuzzleHttp\Client;
|
||||
use support\Log;
|
||||
use Throwable;
|
||||
use Webman\Event\Event;
|
||||
|
||||
/**
|
||||
* 商户异步通知派发服务。
|
||||
*
|
||||
* 负责生成 ePay 兼容通知参数、入队通知任务并实际向商户 notify_url 发起回调。
|
||||
*/
|
||||
class MerchantNotifyDispatcherService extends BaseService
|
||||
{
|
||||
private const PROTOCOL_V1 = 'v1';
|
||||
private const PROTOCOL_V2 = 'v2';
|
||||
private const SUCCESS_RESPONSE = 'success';
|
||||
|
||||
private Client $httpClient;
|
||||
|
||||
public function __construct(
|
||||
protected NotifyService $notifyService,
|
||||
protected NotifyTaskRepository $notifyTaskRepository,
|
||||
protected BizOrderRepository $bizOrderRepository,
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected MerchantApiCredentialRepository $merchantApiCredentialRepository,
|
||||
protected PaymentTypeService $paymentTypeService,
|
||||
protected EpaySignerManager $signerManager
|
||||
) {
|
||||
$this->httpClient = new Client([
|
||||
'timeout' => 10,
|
||||
'connect_timeout' => 10,
|
||||
'verify' => true,
|
||||
'http_errors' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为支付成功创建通知任务,并立即尝试派发一次。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付单
|
||||
* @param BizOrder|null $bizOrder 业务单
|
||||
* @return NotifyTask|null 通知任务;没有 notify_url 时返回 null
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function enqueueAndDispatchPaySuccess(PayOrder $payOrder, ?BizOrder $bizOrder = null): ?NotifyTask
|
||||
{
|
||||
$bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
$notifyUrl = trim((string) ($payOrder->notify_url ?: ($bizOrder?->notify_url ?? '')));
|
||||
if ($notifyUrl === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$task = $this->notifyService->enqueueMerchantNotify([
|
||||
'merchant_id' => (int) $payOrder->merchant_id,
|
||||
'merchant_group_id' => (int) $payOrder->merchant_group_id,
|
||||
'event_type' => NotifyConstant::EVENT_PAY_SUCCESS,
|
||||
'ref_no' => (string) $payOrder->pay_no,
|
||||
'biz_no' => (string) $payOrder->biz_no,
|
||||
'pay_no' => (string) $payOrder->pay_no,
|
||||
'notify_url' => $notifyUrl,
|
||||
'notify_data' => $this->buildPaySuccessPayload($payOrder, $bizOrder),
|
||||
'status' => NotifyConstant::TASK_STATUS_PENDING,
|
||||
]);
|
||||
|
||||
return $this->dispatchTask($task);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为退款成功创建通知任务,并立即尝试派发一次。
|
||||
*
|
||||
* @param RefundOrder $refundOrder 退款单
|
||||
* @return NotifyTask|null 通知任务;没有 notify_url 时返回 null
|
||||
*/
|
||||
public function enqueueAndDispatchRefundSuccess(RefundOrder $refundOrder): ?NotifyTask
|
||||
{
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $refundOrder->biz_no);
|
||||
$payOrder = $this->payOrderRepository->findByPayNo((string) $refundOrder->pay_no);
|
||||
$notifyUrl = trim((string) ($payOrder?->notify_url ?: ($bizOrder?->notify_url ?? '')));
|
||||
if ($notifyUrl === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$task = $this->notifyService->enqueueMerchantNotify([
|
||||
'merchant_id' => (int) $refundOrder->merchant_id,
|
||||
'merchant_group_id' => (int) $refundOrder->merchant_group_id,
|
||||
'event_type' => NotifyConstant::EVENT_REFUND_SUCCESS,
|
||||
'ref_no' => (string) $refundOrder->refund_no,
|
||||
'biz_no' => (string) $refundOrder->biz_no,
|
||||
'pay_no' => (string) $refundOrder->pay_no,
|
||||
'notify_url' => $notifyUrl,
|
||||
'notify_data' => $this->buildRefundSuccessPayload($refundOrder, $payOrder, $bizOrder),
|
||||
'status' => NotifyConstant::TASK_STATUS_PENDING,
|
||||
]);
|
||||
|
||||
return $this->dispatchTask($task);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为清算完成创建通知任务,并立即尝试派发一次。
|
||||
*
|
||||
* 当前清算单只有在 ext_json.notify_url 明确存在时才通知商户。
|
||||
*
|
||||
* @param SettlementOrder $settlementOrder 清算单
|
||||
* @return NotifyTask|null 通知任务;没有 notify_url 时返回 null
|
||||
*/
|
||||
public function enqueueAndDispatchSettlementSuccess(SettlementOrder $settlementOrder): ?NotifyTask
|
||||
{
|
||||
$extJson = (array) ($settlementOrder->ext_json ?? []);
|
||||
$notifyUrl = trim((string) ($extJson['notify_url'] ?? ''));
|
||||
if ($notifyUrl === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$task = $this->notifyService->enqueueMerchantNotify([
|
||||
'merchant_id' => (int) $settlementOrder->merchant_id,
|
||||
'merchant_group_id' => (int) $settlementOrder->merchant_group_id,
|
||||
'event_type' => NotifyConstant::EVENT_SETTLEMENT_SUCCESS,
|
||||
'ref_no' => (string) $settlementOrder->settle_no,
|
||||
'biz_no' => '',
|
||||
'pay_no' => '',
|
||||
'notify_url' => $notifyUrl,
|
||||
'notify_data' => $this->buildSettlementSuccessPayload($settlementOrder),
|
||||
'status' => NotifyConstant::TASK_STATUS_PENDING,
|
||||
]);
|
||||
|
||||
return $this->dispatchTask($task);
|
||||
}
|
||||
|
||||
/**
|
||||
* 派发单个通知任务。
|
||||
*
|
||||
* @param NotifyTask|string $task 通知任务模型或通知号
|
||||
* @return NotifyTask
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
public function dispatchTask(NotifyTask|string $task): NotifyTask
|
||||
{
|
||||
$task = $this->resolveTask($task);
|
||||
if ((int) $task->status === NotifyConstant::TASK_STATUS_SUCCESS) {
|
||||
return $task;
|
||||
}
|
||||
|
||||
$eventName = EventConstant::MERCHANT_NOTIFY_FAILED;
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', (string) $task->notify_url, [
|
||||
'query' => (array) ($task->notify_data ?? []),
|
||||
]);
|
||||
$body = trim((string) $response->getBody());
|
||||
|
||||
if (strtolower($body) === self::SUCCESS_RESPONSE) {
|
||||
$task = $this->notifyService->markTaskSuccess((string) $task->notify_no, [
|
||||
'last_notify_at' => $this->now(),
|
||||
'last_response' => $this->truncateResponse($body),
|
||||
]);
|
||||
$eventName = EventConstant::MERCHANT_NOTIFY_SUCCEEDED;
|
||||
} else {
|
||||
$task = $this->notifyService->markTaskFailed((string) $task->notify_no, [
|
||||
'last_notify_at' => $this->now(),
|
||||
'last_response' => $this->truncateResponse($body !== '' ? $body : '商户未返回 success'),
|
||||
]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::warning(sprintf(
|
||||
'[MerchantNotify] 派发失败 notify_no=%s pay_no=%s error=%s',
|
||||
(string) $task->notify_no,
|
||||
(string) $task->pay_no,
|
||||
$e->getMessage()
|
||||
));
|
||||
|
||||
$task = $this->notifyService->markTaskFailed((string) $task->notify_no, [
|
||||
'last_notify_at' => $this->now(),
|
||||
'last_response' => $this->truncateResponse($e->getMessage()),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->dispatchNotifyTaskEvent($eventName, $task);
|
||||
|
||||
return $task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量重试到期任务。
|
||||
*
|
||||
* @param int $limit 最大处理数量
|
||||
* @return int 实际处理数量
|
||||
*/
|
||||
public function dispatchRetryableTasks(int $limit = 100): int
|
||||
{
|
||||
$limit = max(1, $limit);
|
||||
$count = 0;
|
||||
|
||||
foreach ($this->notifyService->listRetryableTasks() as $task) {
|
||||
if ($count >= $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$this->dispatchTask($task);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建支付成功通知参数。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付单
|
||||
* @param BizOrder|null $bizOrder 业务单
|
||||
* @return array<string, mixed>
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function buildPaySuccessPayload(PayOrder $payOrder, ?BizOrder $bizOrder = null): array
|
||||
{
|
||||
return match ($this->resolveProtocolVersion($payOrder, $bizOrder)) {
|
||||
self::PROTOCOL_V1 => $this->buildV1PaySuccessPayload($payOrder, $bizOrder),
|
||||
self::PROTOCOL_V2 => $this->buildV2PaySuccessPayload($payOrder, $bizOrder),
|
||||
default => throw new ValidationException('订单未记录协议版本,无法发送商户通知'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建退款成功通知参数。
|
||||
*
|
||||
* @param RefundOrder $refundOrder 退款单
|
||||
* @param PayOrder|null $payOrder 支付单
|
||||
* @param BizOrder|null $bizOrder 业务单
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildRefundSuccessPayload(RefundOrder $refundOrder, ?PayOrder $payOrder = null, ?BizOrder $bizOrder = null): array
|
||||
{
|
||||
$payOrder ??= $this->payOrderRepository->findByPayNo((string) $refundOrder->pay_no);
|
||||
$bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $refundOrder->biz_no);
|
||||
$payload = $payOrder
|
||||
? $this->buildBasePaySuccessPayload($payOrder, $bizOrder)
|
||||
: [
|
||||
'pid' => (int) $refundOrder->merchant_id,
|
||||
'trade_no' => (string) $refundOrder->pay_no,
|
||||
'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? ''),
|
||||
'money' => FormatHelper::amount((int) $refundOrder->refund_amount),
|
||||
];
|
||||
|
||||
$payload['trade_status'] = NotifyConstant::EVENT_REFUND_SUCCESS;
|
||||
$payload['refund_no'] = (string) $refundOrder->refund_no;
|
||||
$payload['out_refund_no'] = (string) $refundOrder->merchant_refund_no;
|
||||
$payload['refundmoney'] = FormatHelper::amount((int) $refundOrder->refund_amount);
|
||||
$payload['reducemoney'] = FormatHelper::amount((int) ($bizOrder?->refund_amount ?? $refundOrder->refund_amount));
|
||||
$payload['endtime'] = FormatHelper::dateTime($refundOrder->succeeded_at ?: $this->now());
|
||||
|
||||
return $this->signEventPayload($payload, $this->resolveProtocolVersion($payOrder, $bizOrder), (int) $refundOrder->merchant_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建清算完成通知参数。
|
||||
*
|
||||
* @param SettlementOrder $settlementOrder 清算单
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildSettlementSuccessPayload(SettlementOrder $settlementOrder): array
|
||||
{
|
||||
$payload = [
|
||||
'pid' => (int) $settlementOrder->merchant_id,
|
||||
'trade_status' => NotifyConstant::EVENT_SETTLEMENT_SUCCESS,
|
||||
'settle_no' => (string) $settlementOrder->settle_no,
|
||||
'cycle_type' => (int) $settlementOrder->cycle_type,
|
||||
'cycle_key' => (string) $settlementOrder->cycle_key,
|
||||
'money' => FormatHelper::amount((int) $settlementOrder->accounted_amount),
|
||||
'gross_money' => FormatHelper::amount((int) $settlementOrder->gross_amount),
|
||||
'fee_money' => FormatHelper::amount((int) $settlementOrder->fee_amount),
|
||||
'endtime' => FormatHelper::dateTime($settlementOrder->completed_at ?: $this->now()),
|
||||
];
|
||||
$extJson = (array) ($settlementOrder->ext_json ?? []);
|
||||
$protocol = strtolower(trim((string) ($extJson['_protocol_version'] ?? self::PROTOCOL_V2)));
|
||||
|
||||
return $this->signEventPayload($payload, $protocol, (int) $settlementOrder->merchant_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析协议版本。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付单
|
||||
* @param BizOrder|null $bizOrder 业务单
|
||||
* @return string
|
||||
*/
|
||||
private function resolveProtocolVersion(?PayOrder $payOrder = null, ?BizOrder $bizOrder = null): string
|
||||
{
|
||||
$payExtJson = (array) (($payOrder?->ext_json) ?? []);
|
||||
$bizExtJson = (array) (($bizOrder?->ext_json) ?? []);
|
||||
$version = strtolower(trim((string) ($payExtJson['_protocol_version'] ?? $bizExtJson['_protocol_version'] ?? '')));
|
||||
|
||||
return in_array($version, [self::PROTOCOL_V1, self::PROTOCOL_V2], true) ? $version : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 V1 成功通知。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付单
|
||||
* @param BizOrder|null $bizOrder 业务单
|
||||
* @return array<string, mixed>
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function buildV1PaySuccessPayload(PayOrder $payOrder, ?BizOrder $bizOrder = null): array
|
||||
{
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId((int) $payOrder->merchant_id);
|
||||
$apiKey = trim((string) ($credential?->api_key ?? ''));
|
||||
if ($apiKey === '') {
|
||||
throw new ValidationException('商户 API Key 未配置,无法发送 V1 通知');
|
||||
}
|
||||
|
||||
$payload = $this->buildBasePaySuccessPayload($payOrder, $bizOrder);
|
||||
$payload['trade_status'] = 'TRADE_SUCCESS';
|
||||
$payload['sign_type'] = AuthConstant::API_SIGN_NAME_MD5;
|
||||
$payload['sign'] = $this->signerManager->sign($this->signPayload($payload), AuthConstant::API_SIGN_NAME_MD5, $apiKey);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 V2 成功通知。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付单
|
||||
* @param BizOrder|null $bizOrder 业务单
|
||||
* @return array<string, mixed>
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function buildV2PaySuccessPayload(PayOrder $payOrder, ?BizOrder $bizOrder = null): array
|
||||
{
|
||||
$privateKey = trim((string) config('epay.v2.platform_private_key', ''));
|
||||
if ($privateKey === '') {
|
||||
throw new ValidationException('平台 RSA 私钥未配置,无法发送 V2 通知');
|
||||
}
|
||||
|
||||
$signType = (string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA);
|
||||
$payload = $this->buildBasePaySuccessPayload($payOrder, $bizOrder);
|
||||
$payload['trade_status'] = 'TRADE_SUCCESS';
|
||||
$payload['addtime'] = FormatHelper::dateTime($payOrder->created_at);
|
||||
$payload['endtime'] = FormatHelper::dateTime($payOrder->paid_at ?: $this->now());
|
||||
$payload['timestamp'] = (string) time();
|
||||
$payload['sign_type'] = $signType;
|
||||
$payload['sign'] = $this->signerManager->sign($this->signPayload($payload), $signType, $privateKey);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按协议签名事件通知参数。
|
||||
*
|
||||
* @param array<string, mixed> $payload 通知参数
|
||||
* @param string $protocol 协议版本
|
||||
* @param int $merchantId 商户ID
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function signEventPayload(array $payload, string $protocol, int $merchantId): array
|
||||
{
|
||||
if ($protocol === self::PROTOCOL_V1) {
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
$apiKey = trim((string) ($credential?->api_key ?? ''));
|
||||
if ($apiKey === '') {
|
||||
throw new ValidationException('商户 API Key 未配置,无法发送 V1 通知');
|
||||
}
|
||||
|
||||
$payload['sign_type'] = AuthConstant::API_SIGN_NAME_MD5;
|
||||
$payload['sign'] = $this->signerManager->sign($this->signPayload($payload), AuthConstant::API_SIGN_NAME_MD5, $apiKey);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$privateKey = trim((string) config('epay.v2.platform_private_key', ''));
|
||||
if ($privateKey === '') {
|
||||
throw new ValidationException('平台 RSA 私钥未配置,无法发送 V2 通知');
|
||||
}
|
||||
|
||||
$signType = (string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA);
|
||||
$payload['timestamp'] = (string) time();
|
||||
$payload['sign_type'] = $signType;
|
||||
$payload['sign'] = $this->signerManager->sign($this->signPayload($payload), $signType, $privateKey);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 V1/V2 共用通知参数。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付单
|
||||
* @param BizOrder|null $bizOrder 业务单
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildBasePaySuccessPayload(PayOrder $payOrder, ?BizOrder $bizOrder = null): array
|
||||
{
|
||||
$bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
$bizExtJson = (array) (($bizOrder?->ext_json) ?? []);
|
||||
$merchantExt = (array) ($bizExtJson['merchant'] ?? []);
|
||||
|
||||
$payload = [
|
||||
'pid' => (int) $payOrder->merchant_id,
|
||||
'trade_no' => (string) $payOrder->pay_no,
|
||||
'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? ''),
|
||||
'type' => $this->paymentTypeService->resolveCodeById((int) $payOrder->pay_type_id),
|
||||
'name' => (string) ($bizOrder?->subject ?? ''),
|
||||
'money' => FormatHelper::amount((int) $payOrder->pay_amount),
|
||||
];
|
||||
|
||||
$param = $this->stringifyValue($merchantExt['param'] ?? '');
|
||||
if ($param !== '') {
|
||||
$payload['param'] = $param;
|
||||
}
|
||||
|
||||
$buyer = $this->stringifyValue($merchantExt['buyer'] ?? '');
|
||||
if ($buyer !== '') {
|
||||
$payload['buyer'] = $buyer;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规整待签名参数。
|
||||
*
|
||||
* @param array<string, mixed> $payload 原始参数
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function signPayload(array $payload): array
|
||||
{
|
||||
$params = $payload;
|
||||
unset($params['sign'], $params['sign_type']);
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析通知任务。
|
||||
*
|
||||
* @param NotifyTask|string $task 通知任务模型或通知号
|
||||
* @return NotifyTask
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
private function resolveTask(NotifyTask|string $task): NotifyTask
|
||||
{
|
||||
if ($task instanceof NotifyTask) {
|
||||
return $task;
|
||||
}
|
||||
|
||||
$taskModel = $this->notifyTaskRepository->findByNotifyNo($task);
|
||||
if (!$taskModel) {
|
||||
throw new ResourceNotFoundException('通知任务不存在', ['notify_no' => $task]);
|
||||
}
|
||||
|
||||
return $taskModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送商户通知任务事件。
|
||||
*
|
||||
* @param string $eventName 事件名称
|
||||
* @param NotifyTask $task 通知任务
|
||||
* @return void
|
||||
*/
|
||||
private function dispatchNotifyTaskEvent(string $eventName, NotifyTask $task): void
|
||||
{
|
||||
Event::dispatch($eventName, [
|
||||
'notify_no' => (string) $task->notify_no,
|
||||
'event_type' => (string) $task->event_type,
|
||||
'ref_no' => (string) $task->ref_no,
|
||||
'pay_no' => (string) ($task->pay_no ?? ''),
|
||||
'notify_task' => $task,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化任意值为字符串。
|
||||
*
|
||||
* @param mixed $value 原始值
|
||||
* @return string
|
||||
*/
|
||||
private function stringifyValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return $json !== false ? $json : '';
|
||||
}
|
||||
|
||||
return trim((string) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断响应内容,避免把超长 HTML 整段塞进日志字段。
|
||||
*
|
||||
* @param string $body 响应体
|
||||
* @param int $length 最大长度
|
||||
* @return string
|
||||
*/
|
||||
private function truncateResponse(string $body, int $length = 1000): string
|
||||
{
|
||||
$body = trim($body);
|
||||
if ($body === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return mb_strlen($body) > $length ? mb_substr($body, 0, $length) : $body;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\NotifyConstant;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\model\admin\ChannelNotifyLog;
|
||||
use app\model\payment\NotifyTask;
|
||||
@@ -11,6 +13,7 @@ use app\model\admin\PayCallbackLog;
|
||||
use app\repository\ops\log\ChannelNotifyLogRepository;
|
||||
use app\repository\payment\notify\NotifyTaskRepository;
|
||||
use app\repository\ops\log\PayCallbackLogRepository;
|
||||
use app\service\system\config\SystemConfigRuntimeService;
|
||||
|
||||
/**
|
||||
* 通知服务。
|
||||
@@ -29,12 +32,14 @@ class NotifyService extends BaseService
|
||||
* @param ChannelNotifyLogRepository $channelNotifyLogRepository 渠道通知日志仓库
|
||||
* @param PayCallbackLogRepository $payCallbackLogRepository 支付回调日志仓库
|
||||
* @param NotifyTaskRepository $notifyTaskRepository 通知任务仓库
|
||||
* @param SystemConfigRuntimeService $systemConfigRuntimeService 系统配置运行时服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected ChannelNotifyLogRepository $channelNotifyLogRepository,
|
||||
protected PayCallbackLogRepository $payCallbackLogRepository,
|
||||
protected NotifyTaskRepository $notifyTaskRepository
|
||||
protected NotifyTaskRepository $notifyTaskRepository,
|
||||
protected SystemConfigRuntimeService $systemConfigRuntimeService
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -45,7 +50,7 @@ class NotifyService extends BaseService
|
||||
*
|
||||
* @param array $input 通知数据
|
||||
* @return ChannelNotifyLog 渠道通知日志
|
||||
* @throws InvalidArgumentException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function recordChannelNotify(array $input): ChannelNotifyLog
|
||||
{
|
||||
@@ -54,7 +59,10 @@ class NotifyService extends BaseService
|
||||
$bizNo = trim((string) ($input['biz_no'] ?? ''));
|
||||
|
||||
if ($channelId <= 0 || $bizNo === '') {
|
||||
throw new \InvalidArgumentException('渠道通知入参不完整');
|
||||
throw new ValidationException('渠道通知入参不完整', [
|
||||
'channel_id' => $channelId,
|
||||
'biz_no' => $bizNo,
|
||||
]);
|
||||
}
|
||||
|
||||
// 同一业务单如果已经记录过相同类型的通知,就直接复用旧日志,避免重复落库。
|
||||
@@ -82,36 +90,33 @@ class NotifyService extends BaseService
|
||||
/**
|
||||
* 记录支付回调日志。
|
||||
*
|
||||
* 以支付单号 + 回调类型作为去重依据。
|
||||
* 渠道回调是排障证据,每次请求都要留痕;重复识别交给 request_hash,
|
||||
* 不在日志层吞掉后续通知。
|
||||
*
|
||||
* @param array $input 回调数据
|
||||
* @return PayCallbackLog 支付回调日志
|
||||
* @throws InvalidArgumentException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function recordPayCallback(array $input): PayCallbackLog
|
||||
{
|
||||
$payNo = trim((string) ($input['pay_no'] ?? ''));
|
||||
if ($payNo === '') {
|
||||
throw new \InvalidArgumentException('pay_no 不能为空');
|
||||
throw new ValidationException('pay_no 不能为空', ['pay_no' => $payNo]);
|
||||
}
|
||||
|
||||
$callbackType = (int) ($input['callback_type'] ?? NotifyConstant::CALLBACK_TYPE_ASYNC);
|
||||
$logs = $this->payCallbackLogRepository->listByPayNo($payNo);
|
||||
foreach ($logs as $log) {
|
||||
// 同一支付单的同一类型回调只保留一条,后续重复请求直接返回已有日志。
|
||||
if ((int) $log->callback_type === $callbackType) {
|
||||
return $log;
|
||||
}
|
||||
}
|
||||
$requestData = $input['request_data'] ?? [];
|
||||
|
||||
return $this->payCallbackLogRepository->create([
|
||||
'pay_no' => $payNo,
|
||||
'channel_id' => (int) ($input['channel_id'] ?? 0),
|
||||
'callback_type' => $callbackType,
|
||||
'request_data' => $input['request_data'] ?? [],
|
||||
'request_data' => $requestData,
|
||||
'request_hash' => $this->payloadHash($requestData),
|
||||
'verify_status' => (int) ($input['verify_status'] ?? NotifyConstant::VERIFY_STATUS_UNKNOWN),
|
||||
'process_status' => (int) ($input['process_status'] ?? NotifyConstant::PROCESS_STATUS_PENDING),
|
||||
'process_result' => $input['process_result'] ?? [],
|
||||
'created_at' => $input['created_at'] ?? $this->now(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -125,8 +130,21 @@ class NotifyService extends BaseService
|
||||
*/
|
||||
public function enqueueMerchantNotify(array $input): NotifyTask
|
||||
{
|
||||
$eventType = (string) ($input['event_type'] ?? NotifyConstant::EVENT_PAY_SUCCESS);
|
||||
$refNo = (string) ($input['ref_no'] ?? $input['pay_no'] ?? '');
|
||||
if ($refNo === '') {
|
||||
throw new ValidationException('通知事件引用单号不能为空');
|
||||
}
|
||||
|
||||
$existing = $this->notifyTaskRepository->findByEventRef($eventType, $refNo);
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return $this->notifyTaskRepository->create([
|
||||
'notify_no' => (string) ($input['notify_no'] ?? $this->generateNo('NTF')),
|
||||
'event_type' => $eventType,
|
||||
'ref_no' => $refNo,
|
||||
'merchant_id' => (int) ($input['merchant_id'] ?? 0),
|
||||
'merchant_group_id' => (int) ($input['merchant_group_id'] ?? 0),
|
||||
'biz_no' => (string) ($input['biz_no'] ?? ''),
|
||||
@@ -149,13 +167,13 @@ class NotifyService extends BaseService
|
||||
* @param string $notifyNo 通知号
|
||||
* @param array $input 附加数据
|
||||
* @return NotifyTask 通知任务
|
||||
* @throws InvalidArgumentException
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
public function markTaskSuccess(string $notifyNo, array $input = []): NotifyTask
|
||||
{
|
||||
$task = $this->notifyTaskRepository->findByNotifyNo($notifyNo);
|
||||
if (!$task) {
|
||||
throw new \InvalidArgumentException('通知任务不存在');
|
||||
throw new ResourceNotFoundException('通知任务不存在', ['notify_no' => $notifyNo]);
|
||||
}
|
||||
|
||||
$task->status = NotifyConstant::TASK_STATUS_SUCCESS;
|
||||
@@ -174,13 +192,13 @@ class NotifyService extends BaseService
|
||||
* @param string $notifyNo 通知号
|
||||
* @param array $input 附加数据
|
||||
* @return NotifyTask 通知任务
|
||||
* @throws InvalidArgumentException
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
public function markTaskFailed(string $notifyNo, array $input = []): NotifyTask
|
||||
{
|
||||
$task = $this->notifyTaskRepository->findByNotifyNo($notifyNo);
|
||||
if (!$task) {
|
||||
throw new \InvalidArgumentException('通知任务不存在');
|
||||
throw new ResourceNotFoundException('通知任务不存在', ['notify_no' => $notifyNo]);
|
||||
}
|
||||
|
||||
// 每次失败都累计一次重试,并根据新的次数重新计算下一次触发时间。
|
||||
@@ -189,7 +207,7 @@ class NotifyService extends BaseService
|
||||
$task->retry_count = $retryCount;
|
||||
$task->last_notify_at = $input['last_notify_at'] ?? $this->now();
|
||||
$task->last_response = (string) ($input['last_response'] ?? '');
|
||||
$task->next_retry_at = $this->nextRetryAt($retryCount);
|
||||
$task->next_retry_at = $retryCount >= $this->retryLimit() ? null : $this->nextRetryAt($retryCount);
|
||||
$task->save();
|
||||
|
||||
return $task->refresh();
|
||||
@@ -216,15 +234,49 @@ class NotifyService extends BaseService
|
||||
private function nextRetryAt(int $retryCount): string
|
||||
{
|
||||
$retryCount = max(0, $retryCount);
|
||||
$baseDelay = $this->retryIntervalMinutes() * 60;
|
||||
$delay = match (true) {
|
||||
$retryCount <= 0 => 60,
|
||||
$retryCount === 1 => 300,
|
||||
$retryCount === 2 => 900,
|
||||
default => 1800,
|
||||
$retryCount === 1 => $baseDelay,
|
||||
$retryCount === 2 => $baseDelay * 3,
|
||||
default => $baseDelay * 6,
|
||||
};
|
||||
|
||||
return FormatHelper::timestamp(time() + $delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商户通知最大重试次数。
|
||||
*
|
||||
* @return int 最大重试次数
|
||||
*/
|
||||
private function retryLimit(): int
|
||||
{
|
||||
return max(1, (int) $this->systemConfigRuntimeService->get('pay_notify_retry_limit', 3));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商户通知重试基础间隔。
|
||||
*
|
||||
* @return int 基础间隔,单位分钟
|
||||
*/
|
||||
private function retryIntervalMinutes(): int
|
||||
{
|
||||
return max(1, (int) $this->systemConfigRuntimeService->get('pay_notify_retry_interval', 10));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成稳定的载荷摘要,用于后台识别重复通知。
|
||||
*
|
||||
* @param mixed $payload 原始载荷
|
||||
* @return string SHA-256 摘要
|
||||
*/
|
||||
private function payloadHash(mixed $payload): string
|
||||
{
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
|
||||
|
||||
return hash('sha256', $json !== false ? $json : serialize($payload));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\interface\PaymentInterface;
|
||||
use app\common\interface\PayPluginInterface;
|
||||
use app\exception\PaymentException;
|
||||
@@ -243,7 +244,7 @@ class PaymentPluginFactoryService extends BaseService
|
||||
throw new PaymentException('支付插件不存在', 40401, ['plugin_code' => $pluginCode]);
|
||||
}
|
||||
|
||||
if (!$allowDisabled && (int) $plugin->status !== 1) {
|
||||
if (!$allowDisabled && (int) $plugin->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new PaymentException('支付插件已禁用', 40214, ['plugin_code' => $pluginCode]);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use app\common\util\FormatHelper;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use Throwable;
|
||||
use app\model\payment\PaymentChannel;
|
||||
use app\model\payment\PaymentPollGroup;
|
||||
use app\repository\ops\stat\ChannelDailyStatRepository;
|
||||
@@ -79,6 +80,7 @@ class PaymentRouteResolverService extends BaseService
|
||||
throw new ValidationException('路由参数不合法');
|
||||
}
|
||||
|
||||
// 先锁定商户分组与支付方式的绑定,再进入正式通道选路。
|
||||
$bind = $this->bindRepository->findActiveByMerchantGroupAndPayType($merchantGroupId, $payTypeId);
|
||||
if (!$bind) {
|
||||
throw new ResourceNotFoundException('路由不存在', [
|
||||
@@ -87,13 +89,107 @@ class PaymentRouteResolverService extends BaseService
|
||||
]);
|
||||
}
|
||||
|
||||
$route = $this->resolveRouteSelection(
|
||||
$merchantGroupId,
|
||||
(int) $bind->poll_group_id,
|
||||
$payTypeId,
|
||||
$payAmount,
|
||||
$context
|
||||
);
|
||||
$route['bind'] = $bind;
|
||||
|
||||
return $route;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览商户可用支付方式。
|
||||
*
|
||||
* 这里会遍历所有启用中的支付方式,并复用正式路由解析逻辑筛出真正可用的方式。
|
||||
*
|
||||
* @param int $merchantGroupId 商户分组ID
|
||||
* @param int $payAmount 支付金额(分)
|
||||
* @param array $context 路由上下文
|
||||
* @return array<int, array<string, mixed>> 可用支付方式列表
|
||||
*/
|
||||
public function previewAvailablePayTypes(int $merchantGroupId, int $payAmount, array $context = []): array
|
||||
{
|
||||
if ($merchantGroupId <= 0 || $payAmount <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 预览阶段只拿绑定摘要,不先把所有通道明细一次性拉出来。
|
||||
$bindRows = $this->bindRepository->listSummaryByMerchantGroupId($merchantGroupId);
|
||||
if ($bindRows->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$available = [];
|
||||
foreach ($bindRows as $row) {
|
||||
if ((int) ($row->status ?? 0) !== CommonConstant::STATUS_ENABLED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 每个可用支付方式仍然复用正式选路逻辑,只是最终结果被压成前端可展示摘要。
|
||||
$route = $this->resolveRouteSelection(
|
||||
$merchantGroupId,
|
||||
(int) ($row->poll_group_id ?? 0),
|
||||
(int) ($row->pay_type_id ?? 0),
|
||||
$payAmount,
|
||||
$context
|
||||
);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$selected = $route['selected_channel'];
|
||||
/** @var PaymentChannel $channel */
|
||||
$channel = $selected['channel'];
|
||||
$available[] = [
|
||||
'pay_type_id' => (int) ($row->pay_type_id ?? 0),
|
||||
'code' => (string) ($row->pay_type_code ?? ''),
|
||||
'name' => (string) ($row->pay_type_name ?? ''),
|
||||
'selected_channel_id' => (int) $channel->id,
|
||||
'selected_channel_name' => (string) $channel->name,
|
||||
'selected_channel_mode' => (int) $channel->channel_mode,
|
||||
];
|
||||
}
|
||||
|
||||
return $available;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析指定轮询组与支付方式的可用通道路由。
|
||||
*
|
||||
* 该方法负责轮询组、候选通道、插件和统计数据的加载与过滤,并返回排序后的候选集和最终选中通道。
|
||||
*
|
||||
* @param int $merchantGroupId 商户分组ID
|
||||
* @param int $pollGroupId 轮询组ID
|
||||
* @param int $payTypeId 支付类型ID
|
||||
* @param int $payAmount 支付金额(分)
|
||||
* @param array $context 路由上下文,支持传入 `stat_date` 等辅助参数
|
||||
* @return array{
|
||||
* poll_group: PaymentPollGroup,
|
||||
* candidates: array<int, array<string, mixed>>,
|
||||
* selected_channel: array<string, mixed>
|
||||
* }
|
||||
* @throws ValidationException
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
private function resolveRouteSelection(int $merchantGroupId, int $pollGroupId, int $payTypeId, int $payAmount, array $context = []): array
|
||||
{
|
||||
if ($merchantGroupId <= 0 || $pollGroupId <= 0 || $payTypeId <= 0 || $payAmount <= 0) {
|
||||
throw new ValidationException('路由参数不合法');
|
||||
}
|
||||
|
||||
/** @var PaymentPollGroup|null $pollGroup */
|
||||
$pollGroup = $this->pollGroupRepository->find((int) $bind->poll_group_id);
|
||||
$pollGroup = $this->pollGroupRepository->find($pollGroupId);
|
||||
if (!$pollGroup || (int) $pollGroup->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new ResourceNotFoundException('路由不存在', [
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
'pay_type_id' => $payTypeId,
|
||||
'poll_group_id' => (int) ($bind->poll_group_id ?? 0),
|
||||
'poll_group_id' => $pollGroupId,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -104,9 +200,8 @@ class PaymentRouteResolverService extends BaseService
|
||||
]);
|
||||
}
|
||||
|
||||
// 先拿到轮询组下的编排记录,再去批量加载通道、插件和统计数据,避免逐条查库。
|
||||
// 先把轮询组里的候选通道、插件和渠道统计一次性取齐,再做逐层过滤。
|
||||
$channelIds = $candidateRows->pluck('channel_id')->all();
|
||||
// 先一次性拉出通道和插件信息,避免候选过滤过程中频繁查库。
|
||||
$channels = $this->channelRepository->query()
|
||||
->whereIn('id', $channelIds)
|
||||
->where('status', CommonConstant::STATUS_ENABLED)
|
||||
@@ -115,7 +210,6 @@ class PaymentRouteResolverService extends BaseService
|
||||
$pluginCodes = $channels->pluck('plugin_code')->filter()->unique()->values()->all();
|
||||
$plugins = [];
|
||||
if (!empty($pluginCodes)) {
|
||||
// 通道会复用同一个插件实现,插件信息也按编码批量加载一次即可。
|
||||
$plugins = $this->paymentPluginRepository->query()
|
||||
->whereIn('code', $pluginCodes)
|
||||
->get()
|
||||
@@ -123,9 +217,10 @@ class PaymentRouteResolverService extends BaseService
|
||||
->all();
|
||||
}
|
||||
$paymentType = $this->paymentTypeRepository->find($payTypeId);
|
||||
if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new ValidationException('支付方式不支持');
|
||||
}
|
||||
$payTypeCode = trim((string) ($paymentType->code ?? ''));
|
||||
|
||||
// 默认统计日期取当天,路由预览时也可以由外部显式传入历史日期。
|
||||
$statDate = $context['stat_date'] ?? FormatHelper::timestamp(time(), 'Y-m-d');
|
||||
$payAmount = (int) $payAmount;
|
||||
$eligible = [];
|
||||
@@ -139,7 +234,7 @@ class PaymentRouteResolverService extends BaseService
|
||||
continue;
|
||||
}
|
||||
|
||||
// 先按支付方式收口,避免插件和通道配置不一致时误选。
|
||||
// 通道类型、插件支持、金额区间和日限额都必须同时满足,候选才算有效。
|
||||
if ((int) $channel->pay_type_id !== $payTypeId) {
|
||||
continue;
|
||||
}
|
||||
@@ -150,25 +245,21 @@ class PaymentRouteResolverService extends BaseService
|
||||
continue;
|
||||
}
|
||||
|
||||
// 通道还必须被插件明确支持,才允许进入候选集。
|
||||
$pluginPayTypes = is_array($plugin->pay_types) ? $plugin->pay_types : [];
|
||||
$pluginPayTypes = array_values(array_filter(array_map(static fn ($item) => trim((string) $item), $pluginPayTypes)));
|
||||
if ($payTypeCode === '' || !in_array($payTypeCode, $pluginPayTypes, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 金额区间不匹配的通道直接过滤掉。
|
||||
if (!$this->isAmountAllowed($channel, $payAmount)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 日限额和日成功笔数也要同时校验,防止选中已接近上限的通道。
|
||||
$stat = $this->channelDailyStatRepository->findByChannelAndDate($channelId, $statDate);
|
||||
if (!$this->isDailyLimitAllowed($channel, $payAmount, $statDate, $stat)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 保留排序和择优所需的权重、默认标记和统计指标。
|
||||
$eligible[] = [
|
||||
'channel' => $channel,
|
||||
'poll_group_channel' => $row,
|
||||
@@ -183,7 +274,6 @@ class PaymentRouteResolverService extends BaseService
|
||||
}
|
||||
|
||||
if (empty($eligible)) {
|
||||
// 所有候选都被过滤后,直接判定通道不可用。
|
||||
throw new BusinessStateException('支付通道不可用', [
|
||||
'poll_group_id' => (int) $pollGroup->id,
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
@@ -191,14 +281,12 @@ class PaymentRouteResolverService extends BaseService
|
||||
]);
|
||||
}
|
||||
|
||||
// 按路由模式进行排序,然后再选出最终通道。
|
||||
$routeMode = (int) $pollGroup->route_mode;
|
||||
// 剩余候选再按轮询组策略排序,最终只从排序结果里挑一条。
|
||||
$ordered = $this->sortCandidates($eligible, $routeMode);
|
||||
$selected = $this->selectChannel($ordered, $routeMode, (int) $pollGroup->id);
|
||||
|
||||
// 返回绑定、轮询组、候选集和最终选中项,供路由预览和实际支付共用。
|
||||
return [
|
||||
'bind' => $bind,
|
||||
'poll_group' => $pollGroup,
|
||||
'candidates' => $ordered,
|
||||
'selected_channel' => $selected,
|
||||
|
||||
@@ -35,7 +35,19 @@ class PaymentRouteService extends BaseService
|
||||
{
|
||||
return $this->resolverService->resolveByMerchantGroup($merchantGroupId, $payTypeId, $payAmount, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览商户可用支付方式。
|
||||
*
|
||||
* @param int $merchantGroupId 商户分组ID
|
||||
* @param int $payAmount 支付金额(分)
|
||||
* @param array $context 路由上下文
|
||||
* @return array<int, array<string, mixed>> 可用支付方式列表
|
||||
*/
|
||||
public function previewAvailablePayTypes(int $merchantGroupId, int $payAmount, array $context = []): array
|
||||
{
|
||||
return $this->resolverService->previewAvailablePayTypes($merchantGroupId, $payAmount, $context);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
346
app/service/payment/runtime/PaymentRuntimeMaintenanceService.php
Normal file
346
app/service/payment/runtime/PaymentRuntimeMaintenanceService.php
Normal file
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\PaymentPluginStatusConstant;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\exception\PaymentException;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\service\payment\config\PaymentTypeService;
|
||||
use app\service\payment\order\PayOrderLifecycleService;
|
||||
use support\Log;
|
||||
|
||||
/**
|
||||
* 支付运行时维护服务。
|
||||
*
|
||||
* 定时进程调用本服务完成通知重试、订单超时和主动查单。
|
||||
*/
|
||||
class PaymentRuntimeMaintenanceService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param MerchantNotifyDispatcherService $merchantNotifyDispatcherService 商户通知派发服务
|
||||
* @param PayOrderRepository $payOrderRepository 支付单仓库
|
||||
* @param PayOrderLifecycleService $payOrderLifecycleService 支付单生命周期服务
|
||||
* @param PaymentPluginManager $paymentPluginManager 支付插件管理器
|
||||
* @param PaymentTypeService $paymentTypeService 支付方式服务
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantNotifyDispatcherService $merchantNotifyDispatcherService,
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected PayOrderLifecycleService $payOrderLifecycleService,
|
||||
protected PaymentPluginManager $paymentPluginManager,
|
||||
protected PaymentTypeService $paymentTypeService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试可派发的商户通知。
|
||||
*
|
||||
* @param int $limit 批量数量
|
||||
* @return array<string, int> 执行摘要
|
||||
*/
|
||||
public function retryMerchantNotifies(int $limit = 100): array
|
||||
{
|
||||
return [
|
||||
'dispatched' => $this->merchantNotifyDispatcherService->dispatchRetryableTasks($limit),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将已过期的非终态支付单推进为超时。
|
||||
*
|
||||
* @param int $limit 批量数量
|
||||
* @return array<string, int> 执行摘要
|
||||
*/
|
||||
public function timeoutExpiredPayOrders(int $limit = 100): array
|
||||
{
|
||||
$summary = [
|
||||
'scanned' => 0,
|
||||
'timeout' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
|
||||
foreach ($this->payOrderRepository->listExpiredMutable($this->now(), $limit) as $payOrder) {
|
||||
$summary['scanned']++;
|
||||
|
||||
try {
|
||||
$this->payOrderLifecycleService->timeoutPayOrder((string) $payOrder->pay_no, [
|
||||
'reason' => '系统定时任务检测到支付单已过期',
|
||||
'ext_json' => [
|
||||
'lifecycle' => [
|
||||
'timeout_source' => 'runtime_process',
|
||||
],
|
||||
],
|
||||
]);
|
||||
$summary['timeout']++;
|
||||
} catch (\Throwable $e) {
|
||||
$summary['failed']++;
|
||||
Log::warning(sprintf(
|
||||
'[PaymentRuntimeMaintenance] 支付单超时处理失败 pay_no=%s error=%s',
|
||||
(string) $payOrder->pay_no,
|
||||
$e->getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动查询支付中订单并按上游结果推进状态。
|
||||
*
|
||||
* @param int $limit 批量数量
|
||||
* @param int $minAgeSeconds 支付拉起后至少等待秒数
|
||||
* @return array<string, int> 执行摘要
|
||||
*/
|
||||
public function syncPayingOrdersByQuery(int $limit = 50, int $minAgeSeconds = 60): array
|
||||
{
|
||||
$before = date('Y-m-d H:i:s', time() - max(1, $minAgeSeconds));
|
||||
$summary = [
|
||||
'scanned' => 0,
|
||||
'success' => 0,
|
||||
'failed' => 0,
|
||||
'closed' => 0,
|
||||
'pending' => 0,
|
||||
'error' => 0,
|
||||
];
|
||||
|
||||
foreach ($this->payOrderRepository->listPayingForActiveQuery($before, $limit) as $payOrder) {
|
||||
$summary['scanned']++;
|
||||
|
||||
try {
|
||||
$plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true);
|
||||
$result = $plugin->query($this->buildQueryOrder($payOrder));
|
||||
$normalized = $this->normalizeQueryResult($payOrder, $result);
|
||||
|
||||
if ($normalized['status'] === PaymentPluginStatusConstant::SUCCESS) {
|
||||
$this->payOrderLifecycleService->markPaySuccess((string) $payOrder->pay_no, [
|
||||
'channel_order_no' => $normalized['channel_order_no'],
|
||||
'channel_trade_no' => $normalized['channel_trade_no'],
|
||||
'paid_at' => $normalized['paid_at'] ?: null,
|
||||
'ext_json' => [
|
||||
'plugin' => [
|
||||
'active_query' => $this->buildQuerySnapshot($normalized, $result),
|
||||
],
|
||||
],
|
||||
]);
|
||||
$summary['success']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($normalized['status'] === PaymentPluginStatusConstant::CLOSED) {
|
||||
$this->payOrderLifecycleService->closePayOrder((string) $payOrder->pay_no, [
|
||||
'reason' => '主动查单返回渠道已关闭',
|
||||
'ext_json' => [
|
||||
'plugin' => [
|
||||
'active_query' => $this->buildQuerySnapshot($normalized, $result),
|
||||
],
|
||||
],
|
||||
]);
|
||||
$summary['closed']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($normalized['status'] === PaymentPluginStatusConstant::FAILED) {
|
||||
$this->payOrderLifecycleService->markPayFailed((string) $payOrder->pay_no, [
|
||||
'channel_order_no' => $normalized['channel_order_no'],
|
||||
'channel_trade_no' => $normalized['channel_trade_no'],
|
||||
'channel_error_code' => $normalized['channel_error_code'],
|
||||
'channel_error_msg' => $normalized['channel_error_msg'],
|
||||
'failed_at' => $normalized['failed_at'] ?: null,
|
||||
'ext_json' => [
|
||||
'plugin' => [
|
||||
'active_query' => $this->buildQuerySnapshot($normalized, $result),
|
||||
],
|
||||
],
|
||||
]);
|
||||
$summary['failed']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->recordQuerySnapshot($payOrder, $this->buildQuerySnapshot($normalized, $result));
|
||||
$summary['pending']++;
|
||||
} catch (PaymentException $e) {
|
||||
$this->recordQueryError($payOrder, $e->getMessage(), (string) $e->getCode());
|
||||
$summary['error']++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->recordQueryError($payOrder, $e->getMessage(), 'QUERY_ERROR');
|
||||
$summary['error']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建插件查单参数。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付单
|
||||
* @return array<string, mixed> 查单参数
|
||||
*/
|
||||
private function buildQueryOrder(PayOrder $payOrder): array
|
||||
{
|
||||
return [
|
||||
'pay_no' => (string) $payOrder->pay_no,
|
||||
'order_id' => (string) $payOrder->pay_no,
|
||||
'out_trade_no' => (string) $payOrder->pay_no,
|
||||
'biz_no' => (string) $payOrder->biz_no,
|
||||
'trace_no' => (string) $payOrder->trace_no,
|
||||
'chan_order_no' => (string) ($payOrder->channel_order_no ?? ''),
|
||||
'chan_trade_no' => (string) ($payOrder->channel_trade_no ?? ''),
|
||||
'channel_order_no' => (string) ($payOrder->channel_order_no ?? ''),
|
||||
'channel_trade_no' => (string) ($payOrder->channel_trade_no ?? ''),
|
||||
'pay_type_id' => (int) $payOrder->pay_type_id,
|
||||
'pay_type_code' => $this->paymentTypeService->resolveCodeById((int) $payOrder->pay_type_id),
|
||||
'amount' => (int) $payOrder->pay_amount,
|
||||
'pay_amount' => (int) $payOrder->pay_amount,
|
||||
'client_ip' => (string) ($payOrder->client_ip ?? ''),
|
||||
'_env' => (string) (($payOrder->device ?? '') ?: 'pc'),
|
||||
'extra' => (array) ($payOrder->ext_json ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化插件查单结果。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付单
|
||||
* @param array<string, mixed> $result 插件查单结果
|
||||
* @return array<string, mixed> 归一化结果
|
||||
*/
|
||||
private function normalizeQueryResult(PayOrder $payOrder, array $result): array
|
||||
{
|
||||
$statusText = strtolower(trim((string) ($result['status'] ?? $result['trade_status'] ?? $result['channel_status'] ?? '')));
|
||||
$success = array_key_exists('success', $result) ? (bool) $result['success'] : null;
|
||||
|
||||
$status = match (true) {
|
||||
in_array($statusText, PaymentPluginStatusConstant::successQueryAliases(), true) => PaymentPluginStatusConstant::SUCCESS,
|
||||
in_array($statusText, PaymentPluginStatusConstant::closedQueryAliases(), true) => PaymentPluginStatusConstant::CLOSED,
|
||||
in_array($statusText, PaymentPluginStatusConstant::failedQueryAliases(), true) => PaymentPluginStatusConstant::FAILED,
|
||||
$success === false => PaymentPluginStatusConstant::UNKNOWN,
|
||||
default => PaymentPluginStatusConstant::PENDING,
|
||||
};
|
||||
|
||||
$channelOrderNo = $this->firstText($result, ['channel_order_no', 'chan_order_no', 'out_trade_no']);
|
||||
$channelTradeNo = $this->firstText($result, ['channel_trade_no', 'chan_trade_no', 'trade_no', 'api_trade_no']);
|
||||
$channelStatus = trim((string) ($result['channel_status'] ?? $result['status'] ?? ''));
|
||||
$message = $this->firstText($result, ['message', 'msg', 'channel_error_msg']);
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'raw_status' => $statusText,
|
||||
'channel_order_no' => $channelOrderNo !== '' ? $channelOrderNo : (string) ($payOrder->channel_order_no ?? ''),
|
||||
'channel_trade_no' => $channelTradeNo !== '' ? $channelTradeNo : (string) ($payOrder->channel_trade_no ?? ''),
|
||||
'channel_status' => $channelStatus,
|
||||
'channel_error_code' => $this->firstText($result, ['channel_error_code', 'code']),
|
||||
'channel_error_msg' => $message !== '' ? $message : ($status === PaymentPluginStatusConstant::FAILED ? '主动查单返回支付失败' : ''),
|
||||
'paid_at' => $result['paid_at'] ?? null,
|
||||
'failed_at' => $result['failed_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建支付单内保存的轻量查单快照。
|
||||
*
|
||||
* @param array<string, mixed> $normalized 归一化结果
|
||||
* @param array<string, mixed> $result 插件原始结果
|
||||
* @return array<string, mixed> 快照
|
||||
*/
|
||||
private function buildQuerySnapshot(array $normalized, array $result): array
|
||||
{
|
||||
return [
|
||||
'queried_at' => $this->now(),
|
||||
'status' => (string) $normalized['status'],
|
||||
'raw_status' => (string) ($normalized['raw_status'] ?? ''),
|
||||
'channel_status' => (string) ($normalized['channel_status'] ?? ''),
|
||||
'message' => $this->firstText($result, ['message', 'msg']),
|
||||
'success' => array_key_exists('success', $result) ? (bool) $result['success'] : null,
|
||||
'channel_order_no' => (string) ($normalized['channel_order_no'] ?? ''),
|
||||
'channel_trade_no' => (string) ($normalized['channel_trade_no'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录支付中订单的查单快照。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付单
|
||||
* @param array<string, mixed> $snapshot 查单快照
|
||||
* @return void
|
||||
*/
|
||||
private function recordQuerySnapshot(PayOrder $payOrder, array $snapshot): void
|
||||
{
|
||||
$this->transactionRetry(function () use ($payOrder, $snapshot): void {
|
||||
$latest = $this->payOrderRepository->findForUpdateByPayNo((string) $payOrder->pay_no);
|
||||
if (!$latest || (int) $latest->status !== TradeConstant::ORDER_STATUS_PAYING) {
|
||||
return;
|
||||
}
|
||||
|
||||
$extJson = (array) ($latest->ext_json ?? []);
|
||||
$plugin = (array) ($extJson['plugin'] ?? []);
|
||||
$previous = (array) ($plugin['active_query'] ?? []);
|
||||
$snapshot['query_count'] = (int) ($previous['query_count'] ?? 0) + 1;
|
||||
|
||||
$extJson['plugin'] = array_replace_recursive($plugin, [
|
||||
'active_query' => $snapshot,
|
||||
]);
|
||||
$latest->ext_json = $extJson;
|
||||
$latest->save();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录主动查单异常,异常不推进支付状态。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付单
|
||||
* @param string $message 错误信息
|
||||
* @param string $code 错误码
|
||||
* @return void
|
||||
*/
|
||||
private function recordQueryError(PayOrder $payOrder, string $message, string $code): void
|
||||
{
|
||||
Log::warning(sprintf(
|
||||
'[PaymentRuntimeMaintenance] 主动查单失败 pay_no=%s code=%s error=%s',
|
||||
(string) $payOrder->pay_no,
|
||||
$code,
|
||||
$message
|
||||
));
|
||||
|
||||
$this->recordQuerySnapshot($payOrder, [
|
||||
'queried_at' => $this->now(),
|
||||
'status' => 'error',
|
||||
'raw_status' => '',
|
||||
'channel_status' => '',
|
||||
'message' => $message,
|
||||
'success' => false,
|
||||
'error_code' => $code,
|
||||
'channel_order_no' => (string) ($payOrder->channel_order_no ?? ''),
|
||||
'channel_trade_no' => (string) ($payOrder->channel_trade_no ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从候选字段中取首个非空文本。
|
||||
*
|
||||
* @param array<string, mixed> $data 数据
|
||||
* @param array<int, string> $keys 候选字段
|
||||
* @return string 文本
|
||||
*/
|
||||
private function firstText(array $data, array $keys): string
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
$value = $data[$key] ?? null;
|
||||
if (is_scalar($value)) {
|
||||
$text = trim((string) $value);
|
||||
if ($text !== '') {
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user