mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-04-24 02:54:26 +08:00
重构初始化
This commit is contained in:
191
app/service/payment/runtime/NotifyService.php
Normal file
191
app/service/payment/runtime/NotifyService.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\NotifyConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\model\admin\ChannelNotifyLog;
|
||||
use app\model\payment\NotifyTask;
|
||||
use app\model\admin\PayCallbackLog;
|
||||
use app\repository\ops\log\ChannelNotifyLogRepository;
|
||||
use app\repository\payment\notify\NotifyTaskRepository;
|
||||
use app\repository\ops\log\PayCallbackLogRepository;
|
||||
|
||||
/**
|
||||
* 通知服务。
|
||||
*
|
||||
* 负责渠道通知日志、支付回调日志和商户通知任务的统一管理,核心目标是去重、留痕和可重试。
|
||||
*/
|
||||
class NotifyService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected ChannelNotifyLogRepository $channelNotifyLogRepository,
|
||||
protected PayCallbackLogRepository $payCallbackLogRepository,
|
||||
protected NotifyTaskRepository $notifyTaskRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录渠道通知日志。
|
||||
*
|
||||
* 同一通道、通知类型和业务单号只保留一条重复记录。
|
||||
*/
|
||||
public function recordChannelNotify(array $input): ChannelNotifyLog
|
||||
{
|
||||
$channelId = (int) ($input['channel_id'] ?? 0);
|
||||
$notifyType = (int) ($input['notify_type'] ?? NotifyConstant::NOTIFY_TYPE_ASYNC);
|
||||
$bizNo = trim((string) ($input['biz_no'] ?? ''));
|
||||
|
||||
if ($channelId <= 0 || $bizNo === '') {
|
||||
throw new \InvalidArgumentException('渠道通知入参不完整');
|
||||
}
|
||||
|
||||
if ($duplicate = $this->channelNotifyLogRepository->findDuplicate($channelId, $notifyType, $bizNo)) {
|
||||
return $duplicate;
|
||||
}
|
||||
|
||||
return $this->channelNotifyLogRepository->create([
|
||||
'notify_no' => (string) ($input['notify_no'] ?? $this->generateNo('CNL')),
|
||||
'channel_id' => $channelId,
|
||||
'notify_type' => $notifyType,
|
||||
'biz_no' => $bizNo,
|
||||
'pay_no' => (string) ($input['pay_no'] ?? ''),
|
||||
'channel_request_no' => (string) ($input['channel_request_no'] ?? ''),
|
||||
'channel_trade_no' => (string) ($input['channel_trade_no'] ?? ''),
|
||||
'raw_payload' => $input['raw_payload'] ?? [],
|
||||
'verify_status' => (int) ($input['verify_status'] ?? NotifyConstant::VERIFY_STATUS_UNKNOWN),
|
||||
'process_status' => (int) ($input['process_status'] ?? NotifyConstant::PROCESS_STATUS_PENDING),
|
||||
'retry_count' => (int) ($input['retry_count'] ?? 0),
|
||||
'next_retry_at' => $input['next_retry_at'] ?? null,
|
||||
'last_error' => (string) ($input['last_error'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录支付回调日志。
|
||||
*
|
||||
* 以支付单号 + 回调类型作为去重依据。
|
||||
*/
|
||||
public function recordPayCallback(array $input): PayCallbackLog
|
||||
{
|
||||
$payNo = trim((string) ($input['pay_no'] ?? ''));
|
||||
if ($payNo === '') {
|
||||
throw new \InvalidArgumentException('pay_no 不能为空');
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->payCallbackLogRepository->create([
|
||||
'pay_no' => $payNo,
|
||||
'channel_id' => (int) ($input['channel_id'] ?? 0),
|
||||
'callback_type' => $callbackType,
|
||||
'request_data' => $input['request_data'] ?? [],
|
||||
'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'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建商户通知任务。
|
||||
*
|
||||
* 通常用于支付成功、退款成功或清算完成后的商户异步通知。
|
||||
*/
|
||||
public function enqueueMerchantNotify(array $input): NotifyTask
|
||||
{
|
||||
return $this->notifyTaskRepository->create([
|
||||
'notify_no' => (string) ($input['notify_no'] ?? $this->generateNo('NTF')),
|
||||
'merchant_id' => (int) ($input['merchant_id'] ?? 0),
|
||||
'merchant_group_id' => (int) ($input['merchant_group_id'] ?? 0),
|
||||
'biz_no' => (string) ($input['biz_no'] ?? ''),
|
||||
'pay_no' => (string) ($input['pay_no'] ?? ''),
|
||||
'notify_url' => (string) ($input['notify_url'] ?? ''),
|
||||
'notify_data' => $input['notify_data'] ?? [],
|
||||
'status' => (int) ($input['status'] ?? NotifyConstant::TASK_STATUS_PENDING),
|
||||
'retry_count' => (int) ($input['retry_count'] ?? 0),
|
||||
'next_retry_at' => $input['next_retry_at'] ?? $this->nextRetryAt(0),
|
||||
'last_notify_at' => $input['last_notify_at'] ?? null,
|
||||
'last_response' => (string) ($input['last_response'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记商户通知成功。
|
||||
*
|
||||
* 成功后会刷新最后通知时间和响应内容。
|
||||
*/
|
||||
public function markTaskSuccess(string $notifyNo, array $input = []): NotifyTask
|
||||
{
|
||||
$task = $this->notifyTaskRepository->findByNotifyNo($notifyNo);
|
||||
if (!$task) {
|
||||
throw new \InvalidArgumentException('通知任务不存在');
|
||||
}
|
||||
|
||||
$task->status = NotifyConstant::TASK_STATUS_SUCCESS;
|
||||
$task->last_notify_at = $input['last_notify_at'] ?? $this->now();
|
||||
$task->last_response = (string) ($input['last_response'] ?? '');
|
||||
$task->save();
|
||||
|
||||
return $task->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记商户通知失败并计算下次重试时间。
|
||||
*
|
||||
* 失败后会累计重试次数,并根据退避策略生成下一次重试时间。
|
||||
*/
|
||||
public function markTaskFailed(string $notifyNo, array $input = []): NotifyTask
|
||||
{
|
||||
$task = $this->notifyTaskRepository->findByNotifyNo($notifyNo);
|
||||
if (!$task) {
|
||||
throw new \InvalidArgumentException('通知任务不存在');
|
||||
}
|
||||
|
||||
$retryCount = (int) $task->retry_count + 1;
|
||||
$task->status = NotifyConstant::TASK_STATUS_FAILED;
|
||||
$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->save();
|
||||
|
||||
return $task->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待重试任务。
|
||||
*/
|
||||
public function listRetryableTasks(): iterable
|
||||
{
|
||||
return $this->notifyTaskRepository->listRetryable(NotifyConstant::TASK_STATUS_FAILED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据重试次数计算下次重试时间。
|
||||
*
|
||||
* 使用简单的指数退避思路控制重试频率。
|
||||
*/
|
||||
private function nextRetryAt(int $retryCount): string
|
||||
{
|
||||
$retryCount = max(0, $retryCount);
|
||||
$delay = match (true) {
|
||||
$retryCount <= 0 => 60,
|
||||
$retryCount === 1 => 300,
|
||||
$retryCount === 2 => 900,
|
||||
default => 1800,
|
||||
};
|
||||
|
||||
return FormatHelper::timestamp(time() + $delay);
|
||||
}
|
||||
}
|
||||
|
||||
212
app/service/payment/runtime/PaymentPluginFactoryService.php
Normal file
212
app/service/payment/runtime/PaymentPluginFactoryService.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\interface\PaymentInterface;
|
||||
use app\common\interface\PayPluginInterface;
|
||||
use app\exception\PaymentException;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\model\payment\PaymentChannel;
|
||||
use app\model\payment\PaymentPlugin;
|
||||
use app\repository\payment\config\PaymentChannelRepository;
|
||||
use app\repository\payment\config\PaymentPluginConfRepository;
|
||||
use app\repository\payment\config\PaymentPluginRepository;
|
||||
use app\repository\payment\config\PaymentTypeRepository;
|
||||
|
||||
/**
|
||||
* 支付插件工厂服务。
|
||||
*
|
||||
* 负责解析插件定义、装配配置并实例化插件。
|
||||
*/
|
||||
class PaymentPluginFactoryService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPluginRepository $paymentPluginRepository,
|
||||
protected PaymentPluginConfRepository $paymentPluginConfRepository,
|
||||
protected PaymentChannelRepository $paymentChannelRepository,
|
||||
protected PaymentTypeRepository $paymentTypeRepository
|
||||
) {}
|
||||
|
||||
public function createByChannel(PaymentChannel|int $channel, ?int $payTypeId = null, bool $allowDisabled = false): PaymentInterface & PayPluginInterface
|
||||
{
|
||||
$channelModel = $channel instanceof PaymentChannel
|
||||
? $channel
|
||||
: $this->paymentChannelRepository->find((int) $channel);
|
||||
|
||||
if (!$channelModel) {
|
||||
throw new PaymentException('支付通道不存在', 40402, ['channel_id' => (int) $channel]);
|
||||
}
|
||||
|
||||
$plugin = $this->resolvePlugin((string) $channelModel->plugin_code, $allowDisabled);
|
||||
$payTypeCode = $this->resolvePayTypeCode((int) ($payTypeId ?: $channelModel->pay_type_id));
|
||||
if (!$allowDisabled && !$this->pluginSupportsPayType($plugin, $payTypeCode)) {
|
||||
throw new PaymentException('支付插件不支持当前支付方式', 40210, [
|
||||
'plugin_code' => (string) $plugin->code,
|
||||
'pay_type_code' => $payTypeCode,
|
||||
'channel_id' => (int) $channelModel->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$instance = $this->instantiatePlugin((string) $plugin->class_name);
|
||||
$instance->init($this->buildChannelConfig($channelModel, $plugin));
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public function createByPayOrder(PayOrder $payOrder, bool $allowDisabled = true): PaymentInterface
|
||||
{
|
||||
return $this->createByChannel((int) $payOrder->channel_id, (int) $payOrder->pay_type_id, $allowDisabled);
|
||||
}
|
||||
|
||||
public function ensureChannelSupportsPayType(PaymentChannel $channel, int $payTypeId): void
|
||||
{
|
||||
$plugin = $this->resolvePlugin((string) $channel->plugin_code, false);
|
||||
$payTypeCode = $this->resolvePayTypeCode($payTypeId);
|
||||
|
||||
if (!$this->pluginSupportsPayType($plugin, $payTypeCode)) {
|
||||
throw new PaymentException('支付插件不支持当前支付方式', 40210, [
|
||||
'plugin_code' => (string) $plugin->code,
|
||||
'pay_type_code' => $payTypeCode,
|
||||
'channel_id' => (int) $channel->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function pluginPayTypes(string $pluginCode, bool $allowDisabled = false): array
|
||||
{
|
||||
$plugin = $this->resolvePlugin($pluginCode, $allowDisabled);
|
||||
|
||||
return $this->normalizeCodes($plugin->pay_types ?? []);
|
||||
}
|
||||
|
||||
private function buildChannelConfig(PaymentChannel $channel, PaymentPlugin $plugin): array
|
||||
{
|
||||
$config = [];
|
||||
$configId = (int) $channel->api_config_id;
|
||||
|
||||
if ($configId > 0) {
|
||||
$pluginConf = $this->paymentPluginConfRepository->find($configId);
|
||||
if (!$pluginConf) {
|
||||
throw new PaymentException('支付插件配置不存在', 40403, [
|
||||
'api_config_id' => $configId,
|
||||
'channel_id' => (int) $channel->id,
|
||||
]);
|
||||
}
|
||||
|
||||
if ((string) $pluginConf->plugin_code !== (string) $plugin->code) {
|
||||
throw new PaymentException('支付插件与配置不匹配', 40211, [
|
||||
'channel_id' => (int) $channel->id,
|
||||
'plugin_code' => (string) $plugin->code,
|
||||
'config_plugin_code' => (string) $pluginConf->plugin_code,
|
||||
]);
|
||||
}
|
||||
|
||||
$config = (array) ($pluginConf->config ?? []);
|
||||
$config['settlement_cycle_type'] = (int) ($pluginConf->settlement_cycle_type ?? 1);
|
||||
$config['settlement_cutoff_time'] = (string) ($pluginConf->settlement_cutoff_time ?? '23:59:59');
|
||||
}
|
||||
|
||||
$config['plugin_code'] = (string) $plugin->code;
|
||||
$config['plugin_name'] = (string) $plugin->name;
|
||||
$config['channel_id'] = (int) $channel->id;
|
||||
$config['merchant_id'] = (int) $channel->merchant_id;
|
||||
$config['channel_mode'] = (int) $channel->channel_mode;
|
||||
$config['pay_type_id'] = (int) $channel->pay_type_id;
|
||||
$config['api_config_id'] = $configId;
|
||||
$config['enabled_pay_types'] = $this->normalizeCodes($plugin->pay_types ?? []);
|
||||
$config['enabled_transfer_types'] = $this->normalizeCodes($plugin->transfer_types ?? []);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
private function instantiatePlugin(string $className): PaymentInterface & PayPluginInterface
|
||||
{
|
||||
$className = $this->resolvePluginClassName($className);
|
||||
if ($className === '') {
|
||||
throw new PaymentException('支付插件未配置实现类', 40212);
|
||||
}
|
||||
|
||||
if (!class_exists($className)) {
|
||||
throw new PaymentException('支付插件实现类不存在', 40404, ['class_name' => $className]);
|
||||
}
|
||||
|
||||
$instance = container_make($className, []);
|
||||
if (!$instance instanceof PaymentInterface || !$instance instanceof PayPluginInterface) {
|
||||
throw new PaymentException('支付插件必须同时实现 PaymentInterface 与 PayPluginInterface', 40213, ['class_name' => $className]);
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
private function resolvePluginClassName(string $className): string
|
||||
{
|
||||
$className = trim($className);
|
||||
if ($className === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (str_contains($className, '\\')) {
|
||||
return $className;
|
||||
}
|
||||
|
||||
return 'app\\common\\payment\\' . $className;
|
||||
}
|
||||
|
||||
private function resolvePlugin(string $pluginCode, bool $allowDisabled): PaymentPlugin
|
||||
{
|
||||
/** @var PaymentPlugin|null $plugin */
|
||||
$plugin = $this->paymentPluginRepository->findByCode($pluginCode);
|
||||
if (!$plugin) {
|
||||
throw new PaymentException('支付插件不存在', 40401, ['plugin_code' => $pluginCode]);
|
||||
}
|
||||
|
||||
if (!$allowDisabled && (int) $plugin->status !== 1) {
|
||||
throw new PaymentException('支付插件已禁用', 40214, ['plugin_code' => $pluginCode]);
|
||||
}
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
|
||||
private function resolvePayTypeCode(int $payTypeId): string
|
||||
{
|
||||
$paymentType = $this->paymentTypeRepository->find($payTypeId);
|
||||
if (!$paymentType) {
|
||||
throw new PaymentException('支付方式不存在', 40405, ['pay_type_id' => $payTypeId]);
|
||||
}
|
||||
|
||||
return trim((string) $paymentType->code);
|
||||
}
|
||||
|
||||
private function pluginSupportsPayType(PaymentPlugin $plugin, string $payTypeCode): bool
|
||||
{
|
||||
$payTypeCode = trim($payTypeCode);
|
||||
if ($payTypeCode === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($payTypeCode, $this->normalizeCodes($plugin->pay_types ?? []), true);
|
||||
}
|
||||
|
||||
private function normalizeCodes(mixed $codes): array
|
||||
{
|
||||
if (is_string($codes)) {
|
||||
$decoded = json_decode($codes, true);
|
||||
$codes = is_array($decoded) ? $decoded : [$codes];
|
||||
}
|
||||
|
||||
if (!is_array($codes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($codes as $code) {
|
||||
$value = trim((string) $code);
|
||||
if ($value !== '') {
|
||||
$normalized[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
}
|
||||
42
app/service/payment/runtime/PaymentPluginManager.php
Normal file
42
app/service/payment/runtime/PaymentPluginManager.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\interface\PaymentInterface;
|
||||
use app\common\interface\PayPluginInterface;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\model\payment\PaymentChannel;
|
||||
|
||||
/**
|
||||
* 支付插件门面服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给插件工厂服务。
|
||||
*/
|
||||
class PaymentPluginManager extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPluginFactoryService $factoryService
|
||||
) {
|
||||
}
|
||||
|
||||
public function createByChannel(PaymentChannel|int $channel, ?int $payTypeId = null, bool $allowDisabled = false): PaymentInterface & PayPluginInterface
|
||||
{
|
||||
return $this->factoryService->createByChannel($channel, $payTypeId, $allowDisabled);
|
||||
}
|
||||
|
||||
public function createByPayOrder(PayOrder $payOrder, bool $allowDisabled = true): PaymentInterface & PayPluginInterface
|
||||
{
|
||||
return $this->factoryService->createByPayOrder($payOrder, $allowDisabled);
|
||||
}
|
||||
|
||||
public function ensureChannelSupportsPayType(PaymentChannel $channel, int $payTypeId): void
|
||||
{
|
||||
$this->factoryService->ensureChannelSupportsPayType($channel, $payTypeId);
|
||||
}
|
||||
|
||||
public function pluginPayTypes(string $pluginCode, bool $allowDisabled = false): array
|
||||
{
|
||||
return $this->factoryService->pluginPayTypes($pluginCode, $allowDisabled);
|
||||
}
|
||||
}
|
||||
276
app/service/payment/runtime/PaymentRouteResolverService.php
Normal file
276
app/service/payment/runtime/PaymentRouteResolverService.php
Normal file
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\payment\PaymentChannel;
|
||||
use app\model\payment\PaymentPollGroup;
|
||||
use app\repository\ops\stat\ChannelDailyStatRepository;
|
||||
use app\repository\payment\config\PaymentChannelRepository;
|
||||
use app\repository\payment\config\PaymentPollGroupBindRepository;
|
||||
use app\repository\payment\config\PaymentPollGroupChannelRepository;
|
||||
use app\repository\payment\config\PaymentPollGroupRepository;
|
||||
use app\repository\payment\config\PaymentPluginRepository;
|
||||
use app\repository\payment\config\PaymentTypeRepository;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* 支付路由解析服务。
|
||||
*
|
||||
* 负责商户分组 -> 轮询组 -> 支付通道的编排与选择。
|
||||
*/
|
||||
class PaymentRouteResolverService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPollGroupBindRepository $bindRepository,
|
||||
protected PaymentPollGroupRepository $pollGroupRepository,
|
||||
protected PaymentPollGroupChannelRepository $pollGroupChannelRepository,
|
||||
protected PaymentChannelRepository $channelRepository,
|
||||
protected ChannelDailyStatRepository $channelDailyStatRepository,
|
||||
protected PaymentPluginRepository $paymentPluginRepository,
|
||||
protected PaymentTypeRepository $paymentTypeRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按商户分组和支付方式解析路由。
|
||||
*
|
||||
* @return array{bind:mixed,poll_group:mixed,candidates:array,selected_channel:array}
|
||||
*/
|
||||
public function resolveByMerchantGroup(int $merchantGroupId, int $payTypeId, int $payAmount, array $context = []): array
|
||||
{
|
||||
if ($merchantGroupId <= 0 || $payTypeId <= 0 || $payAmount <= 0) {
|
||||
throw new ValidationException('路由参数不合法');
|
||||
}
|
||||
|
||||
$bind = $this->bindRepository->findActiveByMerchantGroupAndPayType($merchantGroupId, $payTypeId);
|
||||
if (!$bind) {
|
||||
throw new ResourceNotFoundException('路由不存在', [
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
'pay_type_id' => $payTypeId,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var PaymentPollGroup|null $pollGroup */
|
||||
$pollGroup = $this->pollGroupRepository->find((int) $bind->poll_group_id);
|
||||
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),
|
||||
]);
|
||||
}
|
||||
|
||||
$candidateRows = $this->pollGroupChannelRepository->listByPollGroupId((int) $pollGroup->id);
|
||||
if ($candidateRows->isEmpty()) {
|
||||
throw new BusinessStateException('支付通道不可用', [
|
||||
'poll_group_id' => (int) $pollGroup->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$channelIds = $candidateRows->pluck('channel_id')->all();
|
||||
$channels = $this->channelRepository->query()
|
||||
->whereIn('id', $channelIds)
|
||||
->where('status', CommonConstant::STATUS_ENABLED)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
$pluginCodes = $channels->pluck('plugin_code')->filter()->unique()->values()->all();
|
||||
$plugins = [];
|
||||
if (!empty($pluginCodes)) {
|
||||
$plugins = $this->paymentPluginRepository->query()
|
||||
->whereIn('code', $pluginCodes)
|
||||
->get()
|
||||
->keyBy('code')
|
||||
->all();
|
||||
}
|
||||
$paymentType = $this->paymentTypeRepository->find($payTypeId);
|
||||
$payTypeCode = trim((string) ($paymentType->code ?? ''));
|
||||
|
||||
$statDate = $context['stat_date'] ?? FormatHelper::timestamp(time(), 'Y-m-d');
|
||||
$payAmount = (int) $payAmount;
|
||||
$eligible = [];
|
||||
|
||||
foreach ($candidateRows as $row) {
|
||||
$channelId = (int) $row->channel_id;
|
||||
|
||||
/** @var PaymentChannel|null $channel */
|
||||
$channel = $channels->get($channelId);
|
||||
if (!$channel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) $channel->pay_type_id !== $payTypeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$plugin = $plugins[(string) $channel->plugin_code] ?? null;
|
||||
if (!$plugin || (int) $plugin->status !== CommonConstant::STATUS_ENABLED) {
|
||||
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,
|
||||
'daily_stat' => $stat,
|
||||
'health_score' => (int) ($stat->health_score ?? 0),
|
||||
'success_rate_bp' => (int) ($stat->success_rate_bp ?? 0),
|
||||
'avg_latency_ms' => (int) ($stat->avg_latency_ms ?? 0),
|
||||
'weight' => max(1, (int) $row->weight),
|
||||
'is_default' => (int) $row->is_default,
|
||||
'sort_no' => (int) $row->sort_no,
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($eligible)) {
|
||||
throw new BusinessStateException('支付通道不可用', [
|
||||
'poll_group_id' => (int) $pollGroup->id,
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
'pay_type_id' => $payTypeId,
|
||||
]);
|
||||
}
|
||||
|
||||
$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,
|
||||
];
|
||||
}
|
||||
|
||||
private function isAmountAllowed(PaymentChannel $channel, int $payAmount): bool
|
||||
{
|
||||
if ((int) $channel->min_amount > 0 && $payAmount < (int) $channel->min_amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $channel->max_amount > 0 && $payAmount > (int) $channel->max_amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isDailyLimitAllowed(PaymentChannel $channel, int $payAmount, string $statDate, ?object $stat = null): bool
|
||||
{
|
||||
if ((int) $channel->daily_limit_amount <= 0 && (int) $channel->daily_limit_count <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$stat ??= $this->channelDailyStatRepository->findByChannelAndDate((int) $channel->id, $statDate);
|
||||
$currentAmount = (int) ($stat->pay_amount ?? 0);
|
||||
$currentCount = (int) ($stat->pay_success_count ?? 0);
|
||||
|
||||
if ((int) $channel->daily_limit_amount > 0 && $currentAmount + $payAmount > (int) $channel->daily_limit_amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $channel->daily_limit_count > 0 && $currentCount + 1 > (int) $channel->daily_limit_count) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function sortCandidates(array $candidates, int $routeMode): array
|
||||
{
|
||||
usort($candidates, function (array $left, array $right) use ($routeMode) {
|
||||
if (
|
||||
$routeMode === RouteConstant::ROUTE_MODE_FIRST_AVAILABLE
|
||||
&& (int) $left['is_default'] !== (int) $right['is_default']
|
||||
) {
|
||||
return (int) $right['is_default'] <=> (int) $left['is_default'];
|
||||
}
|
||||
|
||||
if ((int) $left['sort_no'] !== (int) $right['sort_no']) {
|
||||
return (int) $left['sort_no'] <=> (int) $right['sort_no'];
|
||||
}
|
||||
|
||||
return (int) $left['channel']->id <=> (int) $right['channel']->id;
|
||||
});
|
||||
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
private function selectChannel(array $candidates, int $routeMode, int $pollGroupId): array
|
||||
{
|
||||
if (count($candidates) === 1) {
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
return match ($routeMode) {
|
||||
RouteConstant::ROUTE_MODE_WEIGHTED => $this->selectWeightedChannel($candidates),
|
||||
RouteConstant::ROUTE_MODE_ORDER => $this->selectSequentialChannel($candidates, $pollGroupId),
|
||||
RouteConstant::ROUTE_MODE_FIRST_AVAILABLE => $this->selectDefaultChannel($candidates),
|
||||
default => $candidates[0],
|
||||
};
|
||||
}
|
||||
|
||||
private function selectWeightedChannel(array $candidates): array
|
||||
{
|
||||
$totalWeight = array_sum(array_map(static fn (array $item) => max(1, (int) $item['weight']), $candidates));
|
||||
$random = random_int(1, max(1, $totalWeight));
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$random -= max(1, (int) $candidate['weight']);
|
||||
if ($random <= 0) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
private function selectSequentialChannel(array $candidates, int $pollGroupId): array
|
||||
{
|
||||
if ($pollGroupId <= 0) {
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
try {
|
||||
$cursorKey = sprintf('payment:route:round_robin:%d', $pollGroupId);
|
||||
$cursor = (int) Redis::incr($cursorKey);
|
||||
Redis::expire($cursorKey, 30 * 86400);
|
||||
$index = max(0, ($cursor - 1) % count($candidates));
|
||||
|
||||
return $candidates[$index] ?? $candidates[0];
|
||||
} catch (\Throwable) {
|
||||
return $candidates[0];
|
||||
}
|
||||
}
|
||||
|
||||
private function selectDefaultChannel(array $candidates): array
|
||||
{
|
||||
foreach ($candidates as $candidate) {
|
||||
if ((int) ($candidate['is_default'] ?? 0) === 1) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates[0];
|
||||
}
|
||||
}
|
||||
26
app/service/payment/runtime/PaymentRouteService.php
Normal file
26
app/service/payment/runtime/PaymentRouteService.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
|
||||
/**
|
||||
* 支付路由门面服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给路由解析服务。
|
||||
*/
|
||||
class PaymentRouteService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentRouteResolverService $resolverService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按商户分组和支付方式解析路由。
|
||||
*/
|
||||
public function resolveByMerchantGroup(int $merchantGroupId, int $payTypeId, int $payAmount, array $context = []): array
|
||||
{
|
||||
return $this->resolverService->resolveByMerchantGroup($merchantGroupId, $payTypeId, $payAmount, $context);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user