1. 维护代码健壮

2. 更新项目结构文档
This commit is contained in:
技术老胡
2026-04-27 16:20:41 +08:00
parent 9a16a88640
commit 0e5de50337
198 changed files with 21038 additions and 702 deletions

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

View File

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

View File

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

View File

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

View File

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

View 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 '';
}
}