mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-04-24 02:54:26 +08:00
codex基础代码更新
This commit is contained in:
103
app/services/ChannelRoutePolicyService.php
Normal file
103
app/services/ChannelRoutePolicyService.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace app\services;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
|
||||
class ChannelRoutePolicyService extends BaseService
|
||||
{
|
||||
private const CONFIG_KEY = 'channel_route_policies';
|
||||
|
||||
public function __construct(
|
||||
protected SystemConfigService $configService
|
||||
) {
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$raw = $this->configService->getValue(self::CONFIG_KEY, '[]');
|
||||
|
||||
if (is_array($raw)) {
|
||||
$policies = $raw;
|
||||
} else {
|
||||
$decoded = json_decode((string)$raw, true);
|
||||
$policies = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
usort($policies, function (array $left, array $right) {
|
||||
return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? ''));
|
||||
});
|
||||
|
||||
return $policies;
|
||||
}
|
||||
|
||||
public function save(array $policyData): array
|
||||
{
|
||||
$policies = $this->list();
|
||||
$id = trim((string)($policyData['id'] ?? ''));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$stored = [
|
||||
'id' => $id !== '' ? $id : $this->generateId(),
|
||||
'policy_name' => trim((string)($policyData['policy_name'] ?? '')),
|
||||
'merchant_id' => (int)($policyData['merchant_id'] ?? 0),
|
||||
'merchant_app_id' => (int)($policyData['merchant_app_id'] ?? 0),
|
||||
'method_code' => trim((string)($policyData['method_code'] ?? '')),
|
||||
'plugin_code' => trim((string)($policyData['plugin_code'] ?? '')),
|
||||
'route_mode' => trim((string)($policyData['route_mode'] ?? 'priority')),
|
||||
'status' => (int)($policyData['status'] ?? 1),
|
||||
'circuit_breaker_threshold' => max(0, min(100, (int)($policyData['circuit_breaker_threshold'] ?? 50))),
|
||||
'failover_cooldown' => max(0, (int)($policyData['failover_cooldown'] ?? 10)),
|
||||
'remark' => trim((string)($policyData['remark'] ?? '')),
|
||||
'items' => array_values($policyData['items'] ?? []),
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$found = false;
|
||||
foreach ($policies as &$policy) {
|
||||
if (($policy['id'] ?? '') !== $stored['id']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stored['created_at'] = $policy['created_at'] ?? $now;
|
||||
$policy = $stored;
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
unset($policy);
|
||||
|
||||
if (!$found) {
|
||||
$stored['created_at'] = $now;
|
||||
$policies[] = $stored;
|
||||
}
|
||||
|
||||
$this->configService->setValue(self::CONFIG_KEY, $policies);
|
||||
|
||||
return $stored;
|
||||
}
|
||||
|
||||
public function delete(string $id): bool
|
||||
{
|
||||
$id = trim($id);
|
||||
if ($id === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$policies = $this->list();
|
||||
$filtered = array_values(array_filter($policies, function (array $policy) use ($id) {
|
||||
return ($policy['id'] ?? '') !== $id;
|
||||
}));
|
||||
|
||||
if (count($filtered) === count($policies)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->configService->setValue(self::CONFIG_KEY, $filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
private function generateId(): string
|
||||
{
|
||||
return 'rp_' . date('YmdHis') . mt_rand(1000, 9999);
|
||||
}
|
||||
}
|
||||
@@ -6,37 +6,608 @@ use app\common\base\BaseService;
|
||||
use app\exceptions\NotFoundException;
|
||||
use app\models\PaymentChannel;
|
||||
use app\repositories\PaymentChannelRepository;
|
||||
use app\repositories\PaymentMethodRepository;
|
||||
use app\repositories\PaymentOrderRepository;
|
||||
|
||||
/**
|
||||
* 通道路由服务
|
||||
*
|
||||
* 负责根据商户、应用、支付方式选择合适的通道
|
||||
* 负责根据商户、应用、支付方式和已保存策略选择可用通道,
|
||||
* 同时也为后台提供策略草稿预览能力,保证预览和真实下单尽量一致。
|
||||
*/
|
||||
class ChannelRouterService extends BaseService
|
||||
{
|
||||
private const HEALTH_LOOKBACK_DAYS = 7;
|
||||
|
||||
public function __construct(
|
||||
protected PaymentChannelRepository $channelRepository
|
||||
protected PaymentChannelRepository $channelRepository,
|
||||
protected PaymentMethodRepository $methodRepository,
|
||||
protected PaymentOrderRepository $orderRepository,
|
||||
protected ChannelRoutePolicyService $routePolicyService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择通道
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param int $merchantAppId 商户应用ID
|
||||
* @param int $methodId 支付方式ID
|
||||
* @return PaymentChannel
|
||||
* @throws NotFoundException
|
||||
* 向后兼容:只返回选中的通道模型。
|
||||
*/
|
||||
public function chooseChannel(int $merchantId, int $merchantAppId, int $methodId): PaymentChannel
|
||||
public function chooseChannel(int $merchantId, int $merchantAppId, int $methodId, float $amount = 0): PaymentChannel
|
||||
{
|
||||
$channel = $this->channelRepository->findAvailableChannel($merchantId, $merchantAppId, $methodId);
|
||||
$decision = $this->chooseChannelWithDecision($merchantId, $merchantAppId, $methodId, $amount);
|
||||
return $decision['channel'];
|
||||
}
|
||||
|
||||
if (!$channel) {
|
||||
throw new NotFoundException("未找到可用的支付通道:商户ID={$merchantId}, 应用ID={$merchantAppId}, 支付方式ID={$methodId}");
|
||||
/**
|
||||
* 返回完整路由决策信息,便于下单链路记录调度痕迹。
|
||||
*
|
||||
* @return array{
|
||||
* channel:PaymentChannel,
|
||||
* source:string,
|
||||
* route_mode:string,
|
||||
* policy:?array,
|
||||
* candidates:array<int, array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
public function chooseChannelWithDecision(int $merchantId, int $merchantAppId, int $methodId, float $amount = 0): array
|
||||
{
|
||||
$routingContext = $this->loadRoutingContexts($merchantId, $merchantAppId, $methodId, $amount);
|
||||
$method = $routingContext['method'];
|
||||
$contexts = $routingContext['contexts'];
|
||||
|
||||
$decision = $this->chooseByPolicy(
|
||||
$merchantId,
|
||||
$merchantAppId,
|
||||
(string)$method->method_code,
|
||||
$contexts
|
||||
);
|
||||
|
||||
if ($decision !== null) {
|
||||
return $decision;
|
||||
}
|
||||
|
||||
return $channel;
|
||||
$decision = $this->chooseFallback($contexts);
|
||||
if ($decision !== null) {
|
||||
return $decision;
|
||||
}
|
||||
|
||||
throw new NotFoundException(
|
||||
$this->buildNoChannelMessage($merchantId, $merchantAppId, (string)$method->method_name, $contexts)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览一个尚未保存的策略草稿在当前真实通道环境下会如何命中。
|
||||
*/
|
||||
public function previewPolicyDraft(
|
||||
int $merchantId,
|
||||
int $merchantAppId,
|
||||
int $methodId,
|
||||
array $policy,
|
||||
float $amount = 0
|
||||
): array {
|
||||
$routingContext = $this->loadRoutingContexts($merchantId, $merchantAppId, $methodId, $amount);
|
||||
$method = $routingContext['method'];
|
||||
$contexts = $routingContext['contexts'];
|
||||
$previewPolicy = $this->normalizePreviewPolicy($policy, $merchantId, $merchantAppId, (string)$method->method_code);
|
||||
$evaluation = $this->evaluatePolicy($previewPolicy, $contexts);
|
||||
|
||||
$selectedChannel = null;
|
||||
if ($evaluation['selected_candidate'] !== null) {
|
||||
$selectedContext = $contexts[(int)$evaluation['selected_candidate']['channel_id']] ?? null;
|
||||
if ($selectedContext !== null) {
|
||||
/** @var PaymentChannel $channel */
|
||||
$channel = $selectedContext['channel'];
|
||||
$selectedChannel = [
|
||||
'id' => (int)$channel->id,
|
||||
'chan_code' => (string)$channel->chan_code,
|
||||
'chan_name' => (string)$channel->chan_name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'matched' => $selectedChannel !== null,
|
||||
'source' => 'preview',
|
||||
'route_mode' => (string)($previewPolicy['route_mode'] ?? 'priority'),
|
||||
'policy' => $this->buildPolicyMeta($previewPolicy),
|
||||
'selected_channel' => $selectedChannel,
|
||||
'candidates' => $evaluation['candidates'],
|
||||
'summary' => [
|
||||
'candidate_count' => count($evaluation['candidates']),
|
||||
'available_count' => count($evaluation['available_candidates']),
|
||||
'blocked_count' => count($evaluation['candidates']) - count($evaluation['available_candidates']),
|
||||
],
|
||||
'message' => $selectedChannel !== null ? '本次模拟已命中策略通道' : '当前策略下没有可用通道',
|
||||
];
|
||||
}
|
||||
|
||||
private function chooseByPolicy(int $merchantId, int $merchantAppId, string $methodCode, array $contexts): ?array
|
||||
{
|
||||
return $this->chooseByPolicies(
|
||||
$merchantId,
|
||||
$merchantAppId,
|
||||
$methodCode,
|
||||
$contexts,
|
||||
$this->routePolicyService->list()
|
||||
);
|
||||
}
|
||||
|
||||
private function chooseFallback(array $contexts): ?array
|
||||
{
|
||||
$candidates = [];
|
||||
foreach ($contexts as $context) {
|
||||
/** @var PaymentChannel $channel */
|
||||
$channel = $context['channel'];
|
||||
$candidates[] = [
|
||||
'channel_id' => (int)$channel->id,
|
||||
'chan_code' => (string)$channel->chan_code,
|
||||
'chan_name' => (string)$channel->chan_name,
|
||||
'available' => $context['available'],
|
||||
'reasons' => $context['reasons'],
|
||||
'priority' => (int)$channel->sort,
|
||||
'weight' => 100,
|
||||
'role' => 'normal',
|
||||
'health_score' => $context['health_score'],
|
||||
'success_rate' => $context['success_rate'],
|
||||
];
|
||||
}
|
||||
|
||||
$availableCandidates = array_values(array_filter($candidates, fn(array $item) => (bool)$item['available']));
|
||||
if ($availableCandidates === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($availableCandidates, function (array $left, array $right) {
|
||||
if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) {
|
||||
if (($right['health_score'] ?? 0) === ($left['health_score'] ?? 0)) {
|
||||
return ($right['success_rate'] ?? 0) <=> ($left['success_rate'] ?? 0);
|
||||
}
|
||||
return ($right['health_score'] ?? 0) <=> ($left['health_score'] ?? 0);
|
||||
}
|
||||
return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0);
|
||||
});
|
||||
|
||||
$selectedCandidate = $availableCandidates[0];
|
||||
$selectedContext = $contexts[(int)$selectedCandidate['channel_id']] ?? null;
|
||||
if (!$selectedContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'channel' => $selectedContext['channel'],
|
||||
'source' => 'fallback',
|
||||
'route_mode' => 'sort',
|
||||
'policy' => null,
|
||||
'candidates' => $candidates,
|
||||
];
|
||||
}
|
||||
|
||||
private function loadRoutingContexts(int $merchantId, int $merchantAppId, int $methodId, float $amount): array
|
||||
{
|
||||
$method = $this->methodRepository->find($methodId);
|
||||
if (!$method) {
|
||||
throw new NotFoundException("未找到支付方式:{$methodId}");
|
||||
}
|
||||
|
||||
$channels = $this->channelRepository->searchList([
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_app_id' => $merchantAppId,
|
||||
'method_id' => $methodId,
|
||||
]);
|
||||
|
||||
if ($channels->isEmpty()) {
|
||||
throw new NotFoundException(
|
||||
"未找到可用的支付通道:商户ID={$merchantId}, 应用ID={$merchantAppId}, 支付方式ID={$methodId}"
|
||||
);
|
||||
}
|
||||
|
||||
$todayRange = $this->getDateRange(1);
|
||||
$recentRange = $this->getDateRange(self::HEALTH_LOOKBACK_DAYS);
|
||||
$channelIds = [];
|
||||
foreach ($channels as $channel) {
|
||||
$channelIds[] = (int)$channel->id;
|
||||
}
|
||||
|
||||
$todayStatsMap = $this->orderRepository->aggregateByChannel($channelIds, [
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_app_id' => $merchantAppId,
|
||||
'method_id' => $methodId,
|
||||
'created_from' => $todayRange['created_from'],
|
||||
'created_to' => $todayRange['created_to'],
|
||||
]);
|
||||
$recentStatsMap = $this->orderRepository->aggregateByChannel($channelIds, [
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_app_id' => $merchantAppId,
|
||||
'method_id' => $methodId,
|
||||
'created_from' => $recentRange['created_from'],
|
||||
'created_to' => $recentRange['created_to'],
|
||||
]);
|
||||
|
||||
$contexts = [];
|
||||
foreach ($channels as $channel) {
|
||||
$contexts[(int)$channel->id] = $this->buildChannelContext(
|
||||
$channel,
|
||||
$todayStatsMap[(int)$channel->id] ?? [],
|
||||
$recentStatsMap[(int)$channel->id] ?? [],
|
||||
$amount
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'method' => $method,
|
||||
'contexts' => $contexts,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildChannelContext(PaymentChannel $channel, array $todayStats, array $recentStats, float $amount): array
|
||||
{
|
||||
$reasons = [];
|
||||
$status = (int)$channel->status;
|
||||
|
||||
$todayOrders = (int)($todayStats['total_orders'] ?? 0);
|
||||
$todaySuccessAmount = round((float)($todayStats['success_amount'] ?? 0), 2);
|
||||
$recentTotalOrders = (int)($recentStats['total_orders'] ?? 0);
|
||||
$recentSuccessOrders = (int)($recentStats['success_orders'] ?? 0);
|
||||
$recentPendingOrders = (int)($recentStats['pending_orders'] ?? 0);
|
||||
$recentFailOrders = (int)($recentStats['fail_orders'] ?? 0);
|
||||
|
||||
$dailyLimit = (float)$channel->daily_limit;
|
||||
$dailyCount = (int)$channel->daily_cnt;
|
||||
$minAmount = $channel->min_amount === null ? null : (float)$channel->min_amount;
|
||||
$maxAmount = $channel->max_amount === null ? null : (float)$channel->max_amount;
|
||||
|
||||
if ($status !== 1) {
|
||||
$reasons[] = '通道已禁用';
|
||||
}
|
||||
if ($amount > 0 && $minAmount !== null && $amount < $minAmount) {
|
||||
$reasons[] = '低于最小支付金额';
|
||||
}
|
||||
if ($amount > 0 && $maxAmount !== null && $maxAmount > 0 && $amount > $maxAmount) {
|
||||
$reasons[] = '超过最大支付金额';
|
||||
}
|
||||
if ($dailyLimit > 0 && $todaySuccessAmount + max(0, $amount) > $dailyLimit) {
|
||||
$reasons[] = '超出单日限额';
|
||||
}
|
||||
if ($dailyCount > 0 && $todayOrders + 1 > $dailyCount) {
|
||||
$reasons[] = '超出单日笔数限制';
|
||||
}
|
||||
|
||||
$successRate = $recentTotalOrders > 0 ? round($recentSuccessOrders / $recentTotalOrders * 100, 2) : 0;
|
||||
$dailyLimitUsageRate = $dailyLimit > 0 ? round(min(100, ($todaySuccessAmount / $dailyLimit) * 100), 2) : null;
|
||||
$healthScore = $this->calculateHealthScore(
|
||||
$status,
|
||||
$recentTotalOrders,
|
||||
$recentSuccessOrders,
|
||||
$recentPendingOrders,
|
||||
$recentFailOrders,
|
||||
$dailyLimitUsageRate
|
||||
);
|
||||
|
||||
return [
|
||||
'channel' => $channel,
|
||||
'available' => $reasons === [],
|
||||
'reasons' => $reasons,
|
||||
'success_rate' => $successRate,
|
||||
'health_score' => $healthScore,
|
||||
'today_orders' => $todayOrders,
|
||||
'today_success_amount' => $todaySuccessAmount,
|
||||
];
|
||||
}
|
||||
|
||||
private function calculateHealthScore(
|
||||
int $status,
|
||||
int $totalOrders,
|
||||
int $successOrders,
|
||||
int $pendingOrders,
|
||||
int $failOrders,
|
||||
?float $todayLimitUsageRate
|
||||
): int {
|
||||
if ($status !== 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($totalOrders === 0) {
|
||||
return 60;
|
||||
}
|
||||
|
||||
$successRate = $totalOrders > 0 ? ($successOrders / $totalOrders * 100) : 0;
|
||||
$healthScore = 90;
|
||||
|
||||
if ($successRate < 95) {
|
||||
$healthScore -= 10;
|
||||
}
|
||||
if ($successRate < 80) {
|
||||
$healthScore -= 15;
|
||||
}
|
||||
if ($successRate < 60) {
|
||||
$healthScore -= 20;
|
||||
}
|
||||
if ($failOrders > 0) {
|
||||
$healthScore -= min(15, $failOrders * 3);
|
||||
}
|
||||
if ($pendingOrders > max(3, (int)floor($successOrders / 2))) {
|
||||
$healthScore -= 10;
|
||||
}
|
||||
if ($todayLimitUsageRate !== null && $todayLimitUsageRate >= 90) {
|
||||
$healthScore -= 20;
|
||||
} elseif ($todayLimitUsageRate !== null && $todayLimitUsageRate >= 75) {
|
||||
$healthScore -= 10;
|
||||
}
|
||||
|
||||
return max(0, min(100, $healthScore));
|
||||
}
|
||||
|
||||
private function chooseByPolicies(
|
||||
int $merchantId,
|
||||
int $merchantAppId,
|
||||
string $methodCode,
|
||||
array $contexts,
|
||||
array $policies
|
||||
): ?array {
|
||||
$matchedPolicies = array_values(array_filter($policies, function (array $policy) use (
|
||||
$merchantId,
|
||||
$merchantAppId,
|
||||
$methodCode
|
||||
) {
|
||||
if ((int)($policy['status'] ?? 0) !== 1) {
|
||||
return false;
|
||||
}
|
||||
if (($policy['method_code'] ?? '') !== $methodCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$policyMerchantId = (int)($policy['merchant_id'] ?? 0);
|
||||
if ($policyMerchantId > 0 && $policyMerchantId !== $merchantId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$policyAppId = (int)($policy['merchant_app_id'] ?? 0);
|
||||
if ($policyAppId > 0 && $policyAppId !== $merchantAppId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return is_array($policy['items'] ?? null) && $policy['items'] !== [];
|
||||
}));
|
||||
|
||||
if ($matchedPolicies === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($matchedPolicies, function (array $left, array $right) {
|
||||
$leftScore = $this->calculatePolicySpecificity($left);
|
||||
$rightScore = $this->calculatePolicySpecificity($right);
|
||||
if ($leftScore === $rightScore) {
|
||||
return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? ''));
|
||||
}
|
||||
return $rightScore <=> $leftScore;
|
||||
});
|
||||
|
||||
foreach ($matchedPolicies as $policy) {
|
||||
$evaluation = $this->evaluatePolicy($policy, $contexts);
|
||||
if ($evaluation['selected_candidate'] === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$selectedContext = $contexts[(int)$evaluation['selected_candidate']['channel_id']] ?? null;
|
||||
if (!$selectedContext) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
'channel' => $selectedContext['channel'],
|
||||
'source' => 'policy',
|
||||
'route_mode' => (string)($policy['route_mode'] ?? 'priority'),
|
||||
'policy' => $this->buildPolicyMeta($policy),
|
||||
'candidates' => $evaluation['candidates'],
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizePolicyItems(array $items): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($items as $index => $item) {
|
||||
$normalized[] = [
|
||||
'channel_id' => (int)($item['channel_id'] ?? 0),
|
||||
'role' => (string)($item['role'] ?? ($index === 0 ? 'primary' : 'backup')),
|
||||
'weight' => max(0, (int)($item['weight'] ?? 100)),
|
||||
'priority' => max(1, (int)($item['priority'] ?? ($index + 1))),
|
||||
];
|
||||
}
|
||||
|
||||
usort($normalized, function (array $left, array $right) {
|
||||
if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) {
|
||||
if (($left['role'] ?? '') === ($right['role'] ?? '')) {
|
||||
return ($right['weight'] ?? 0) <=> ($left['weight'] ?? 0);
|
||||
}
|
||||
|
||||
return ($left['role'] ?? '') === 'primary' ? -1 : 1;
|
||||
}
|
||||
|
||||
return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0);
|
||||
});
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function evaluatePolicy(array $policy, array $contexts): array
|
||||
{
|
||||
$items = $this->normalizePolicyItems($policy['items'] ?? []);
|
||||
$candidates = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$channelId = (int)($item['channel_id'] ?? 0);
|
||||
$context = $contexts[$channelId] ?? null;
|
||||
if (!$context) {
|
||||
$candidates[] = [
|
||||
'channel_id' => $channelId,
|
||||
'chan_code' => '',
|
||||
'chan_name' => '',
|
||||
'available' => false,
|
||||
'reasons' => ['通道不存在或不属于当前应用'],
|
||||
'priority' => (int)($item['priority'] ?? 1),
|
||||
'weight' => (int)($item['weight'] ?? 100),
|
||||
'role' => (string)($item['role'] ?? 'backup'),
|
||||
'health_score' => 0,
|
||||
'success_rate' => 0,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var PaymentChannel $channel */
|
||||
$channel = $context['channel'];
|
||||
$pluginCode = trim((string)($policy['plugin_code'] ?? ''));
|
||||
$policyReasons = [];
|
||||
if ($pluginCode !== '' && (string)$channel->plugin_code !== $pluginCode) {
|
||||
$policyReasons[] = '插件与策略限定不匹配';
|
||||
}
|
||||
|
||||
$available = $context['available'] && $policyReasons === [];
|
||||
$candidates[] = [
|
||||
'channel_id' => (int)$channel->id,
|
||||
'chan_code' => (string)$channel->chan_code,
|
||||
'chan_name' => (string)$channel->chan_name,
|
||||
'available' => $available,
|
||||
'reasons' => $available ? [] : array_values(array_unique(array_merge($context['reasons'], $policyReasons))),
|
||||
'priority' => (int)($item['priority'] ?? 1),
|
||||
'weight' => (int)($item['weight'] ?? 100),
|
||||
'role' => (string)($item['role'] ?? 'backup'),
|
||||
'health_score' => $context['health_score'],
|
||||
'success_rate' => $context['success_rate'],
|
||||
];
|
||||
}
|
||||
|
||||
$availableCandidates = array_values(array_filter($candidates, fn(array $item) => (bool)$item['available']));
|
||||
$selectedCandidate = $availableCandidates === []
|
||||
? null
|
||||
: $this->pickCandidateByMode($availableCandidates, (string)($policy['route_mode'] ?? 'priority'));
|
||||
|
||||
return [
|
||||
'candidates' => $candidates,
|
||||
'available_candidates' => $availableCandidates,
|
||||
'selected_candidate' => $selectedCandidate,
|
||||
];
|
||||
}
|
||||
|
||||
private function pickCandidateByMode(array $candidates, string $routeMode): array
|
||||
{
|
||||
usort($candidates, function (array $left, array $right) {
|
||||
if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) {
|
||||
if (($left['role'] ?? '') === ($right['role'] ?? '')) {
|
||||
return ($right['weight'] ?? 0) <=> ($left['weight'] ?? 0);
|
||||
}
|
||||
|
||||
return ($left['role'] ?? '') === 'primary' ? -1 : 1;
|
||||
}
|
||||
|
||||
return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0);
|
||||
});
|
||||
|
||||
if ($routeMode !== 'weight') {
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
$totalWeight = 0;
|
||||
foreach ($candidates as $candidate) {
|
||||
$totalWeight += max(0, (int)($candidate['weight'] ?? 0));
|
||||
}
|
||||
|
||||
if ($totalWeight <= 0) {
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
$cursor = mt_rand(1, $totalWeight);
|
||||
foreach ($candidates as $candidate) {
|
||||
$cursor -= max(0, (int)($candidate['weight'] ?? 0));
|
||||
if ($cursor <= 0) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
private function calculatePolicySpecificity(array $policy): int
|
||||
{
|
||||
$score = 0;
|
||||
if ((int)($policy['merchant_id'] ?? 0) > 0) {
|
||||
$score += 10;
|
||||
}
|
||||
if ((int)($policy['merchant_app_id'] ?? 0) > 0) {
|
||||
$score += 20;
|
||||
}
|
||||
if (trim((string)($policy['plugin_code'] ?? '')) !== '') {
|
||||
$score += 5;
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
private function buildPolicyMeta(array $policy): array
|
||||
{
|
||||
return [
|
||||
'id' => (string)($policy['id'] ?? ''),
|
||||
'policy_name' => (string)($policy['policy_name'] ?? ''),
|
||||
'plugin_code' => (string)($policy['plugin_code'] ?? ''),
|
||||
'circuit_breaker_threshold' => (int)($policy['circuit_breaker_threshold'] ?? 0),
|
||||
'failover_cooldown' => (int)($policy['failover_cooldown'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildNoChannelMessage(int $merchantId, int $merchantAppId, string $methodName, array $contexts): string
|
||||
{
|
||||
$messages = [];
|
||||
foreach ($contexts as $context) {
|
||||
/** @var PaymentChannel $channel */
|
||||
$channel = $context['channel'];
|
||||
$reasonText = $context['reasons'] === [] ? '无可用原因记录' : implode('、', $context['reasons']);
|
||||
$messages[] = sprintf('%s(%s):%s', (string)$channel->chan_name, (string)$channel->chan_code, $reasonText);
|
||||
}
|
||||
|
||||
usort($messages, fn(string $left, string $right) => strcmp($left, $right));
|
||||
$messages = array_slice($messages, 0, 3);
|
||||
|
||||
$suffix = $messages === [] ? '' : ',原因:' . implode(';', $messages);
|
||||
|
||||
return sprintf(
|
||||
'未找到可用的支付通道:商户ID=%d,应用ID=%d,支付方式=%s%s',
|
||||
$merchantId,
|
||||
$merchantAppId,
|
||||
$methodName,
|
||||
$suffix
|
||||
);
|
||||
}
|
||||
|
||||
private function getDateRange(int $days): array
|
||||
{
|
||||
$days = max(1, $days);
|
||||
return [
|
||||
'created_from' => date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')),
|
||||
'created_to' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePreviewPolicy(array $policy, int $merchantId, int $merchantAppId, string $methodCode): array
|
||||
{
|
||||
$routeMode = trim((string)($policy['route_mode'] ?? 'priority'));
|
||||
if (!in_array($routeMode, ['priority', 'weight', 'failover'], true)) {
|
||||
$routeMode = 'priority';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => trim((string)($policy['id'] ?? 'preview_policy')),
|
||||
'policy_name' => trim((string)($policy['policy_name'] ?? '策略草稿')),
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_app_id' => $merchantAppId,
|
||||
'method_code' => $methodCode,
|
||||
'plugin_code' => trim((string)($policy['plugin_code'] ?? '')),
|
||||
'route_mode' => $routeMode,
|
||||
'status' => 1,
|
||||
'circuit_breaker_threshold' => max(0, min(100, (int)($policy['circuit_breaker_threshold'] ?? 50))),
|
||||
'failover_cooldown' => max(0, (int)($policy['failover_cooldown'] ?? 10)),
|
||||
'items' => $this->normalizePolicyItems($policy['items'] ?? []),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
199
app/services/PayNotifyService.php
Normal file
199
app/services/PayNotifyService.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace app\services;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\repositories\CallbackInboxRepository;
|
||||
use app\repositories\PaymentChannelRepository;
|
||||
use app\repositories\PaymentCallbackLogRepository;
|
||||
use app\repositories\PaymentOrderRepository;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 支付回调处理服务
|
||||
*
|
||||
* 流程:验签 -> 幂等 -> 更新订单 -> 创建商户通知任务。
|
||||
*/
|
||||
class PayNotifyService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PluginService $pluginService,
|
||||
protected PaymentStateService $paymentStateService,
|
||||
protected CallbackInboxRepository $callbackInboxRepository,
|
||||
protected PaymentChannelRepository $channelRepository,
|
||||
protected PaymentCallbackLogRepository $callbackLogRepository,
|
||||
protected PaymentOrderRepository $orderRepository,
|
||||
protected NotifyService $notifyService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool,already?:bool,msg:string,order_id?:string}
|
||||
*/
|
||||
public function handleNotify(string $pluginCode, Request $request): array
|
||||
{
|
||||
$rawPayload = array_merge($request->get(), $request->post());
|
||||
$candidateOrderId = $this->extractOrderIdFromPayload($rawPayload);
|
||||
$order = $candidateOrderId !== '' ? $this->orderRepository->findByOrderId($candidateOrderId) : null;
|
||||
|
||||
try {
|
||||
$plugin = $this->pluginService->getPluginInstance($pluginCode);
|
||||
|
||||
// 验签前初始化插件配置,保证如支付宝证书验签等能力可用。
|
||||
if ($order && (int)$order->channel_id > 0) {
|
||||
$channel = $this->channelRepository->find((int)$order->channel_id);
|
||||
if ($channel) {
|
||||
if ((string)$channel->plugin_code !== $pluginCode) {
|
||||
return ['ok' => false, 'msg' => 'plugin mismatch'];
|
||||
}
|
||||
$channelConfig = array_merge(
|
||||
$channel->getConfigArray(),
|
||||
['enabled_products' => $channel->getEnabledProducts()]
|
||||
);
|
||||
$plugin->init($channelConfig);
|
||||
}
|
||||
}
|
||||
|
||||
$notifyData = $plugin->notify($request);
|
||||
} catch (\Throwable $e) {
|
||||
$this->callbackLogRepository->createLog([
|
||||
'order_id' => $candidateOrderId,
|
||||
'channel_id' => $order ? (int)$order->channel_id : 0,
|
||||
'callback_type' => 'notify',
|
||||
'request_data' => json_encode($rawPayload, JSON_UNESCAPED_UNICODE),
|
||||
'verify_status' => 0,
|
||||
'process_status' => 0,
|
||||
'process_result' => $e->getMessage(),
|
||||
]);
|
||||
return ['ok' => false, 'msg' => 'verify failed'];
|
||||
}
|
||||
|
||||
$orderId = (string)($notifyData['pay_order_id'] ?? '');
|
||||
$status = strtolower((string)($notifyData['status'] ?? ''));
|
||||
$chanTradeNo = (string)($notifyData['chan_trade_no'] ?? '');
|
||||
|
||||
if ($orderId === '') {
|
||||
return ['ok' => false, 'msg' => 'missing pay_order_id'];
|
||||
}
|
||||
|
||||
// 已验签但状态非 success 时,也走状态机进行失败态收敛。
|
||||
if ($status !== 'success') {
|
||||
$order = $this->orderRepository->findByOrderId($orderId);
|
||||
if ($order) {
|
||||
try {
|
||||
$this->paymentStateService->markFailed($order);
|
||||
} catch (\Throwable $e) {
|
||||
// 非法迁移不影响回调日志记录
|
||||
}
|
||||
}
|
||||
|
||||
$this->callbackLogRepository->createLog([
|
||||
'order_id' => $orderId,
|
||||
'channel_id' => $order ? (int)$order->channel_id : 0,
|
||||
'callback_type' => 'notify',
|
||||
'request_data' => json_encode($rawPayload, JSON_UNESCAPED_UNICODE),
|
||||
'verify_status' => 1,
|
||||
'process_status' => 0,
|
||||
'process_result' => 'notify status is not success',
|
||||
]);
|
||||
|
||||
return ['ok' => false, 'msg' => 'notify status is not success'];
|
||||
}
|
||||
|
||||
$eventKey = $this->buildEventKey($pluginCode, $orderId, $chanTradeNo, $notifyData);
|
||||
$payload = $rawPayload;
|
||||
|
||||
$inserted = $this->callbackInboxRepository->createIfAbsent([
|
||||
'event_key' => $eventKey,
|
||||
'plugin_code' => $pluginCode,
|
||||
'order_id' => $orderId,
|
||||
'chan_trade_no' => $chanTradeNo,
|
||||
'payload' => $payload,
|
||||
'process_status' => 0,
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
if (!$inserted) {
|
||||
return ['ok' => true, 'already' => true, 'msg' => 'success', 'order_id' => $orderId];
|
||||
}
|
||||
|
||||
$order = $this->orderRepository->findByOrderId($orderId);
|
||||
if (!$order) {
|
||||
$this->callbackLogRepository->createLog([
|
||||
'order_id' => $orderId,
|
||||
'channel_id' => 0,
|
||||
'callback_type' => 'notify',
|
||||
'request_data' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
'verify_status' => 1,
|
||||
'process_status' => 0,
|
||||
'process_result' => 'order not found',
|
||||
]);
|
||||
|
||||
return ['ok' => false, 'msg' => 'order not found'];
|
||||
}
|
||||
|
||||
try {
|
||||
$this->transaction(function () use ($order, $chanTradeNo, $payload, $pluginCode) {
|
||||
$this->paymentStateService->markPaid($order, $chanTradeNo);
|
||||
|
||||
$this->callbackLogRepository->createLog([
|
||||
'order_id' => $order->order_id,
|
||||
'channel_id' => (int)$order->channel_id,
|
||||
'callback_type' => 'notify',
|
||||
'request_data' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
'verify_status' => 1,
|
||||
'process_status' => 1,
|
||||
'process_result' => 'success:' . $pluginCode,
|
||||
]);
|
||||
|
||||
$this->notifyService->createNotifyTask($order->order_id);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$this->callbackLogRepository->createLog([
|
||||
'order_id' => $order->order_id,
|
||||
'channel_id' => (int)$order->channel_id,
|
||||
'callback_type' => 'notify',
|
||||
'request_data' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
'verify_status' => 1,
|
||||
'process_status' => 0,
|
||||
'process_result' => $e->getMessage(),
|
||||
]);
|
||||
return ['ok' => false, 'msg' => 'process failed'];
|
||||
}
|
||||
|
||||
$event = $this->callbackInboxRepository->findByEventKey($eventKey);
|
||||
if ($event) {
|
||||
$this->callbackInboxRepository->updateById((int)$event->id, [
|
||||
'process_status' => 1,
|
||||
'processed_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
return ['ok' => true, 'msg' => 'success', 'order_id' => $orderId];
|
||||
}
|
||||
|
||||
private function buildEventKey(string $pluginCode, string $orderId, string $chanTradeNo, array $notifyData): string
|
||||
{
|
||||
$base = $pluginCode . '|' . $orderId . '|' . $chanTradeNo . '|' . ($notifyData['status'] ?? '');
|
||||
return sha1($base);
|
||||
}
|
||||
|
||||
private function extractOrderIdFromPayload(array $payload): string
|
||||
{
|
||||
$candidates = [
|
||||
$payload['pay_order_id'] ?? null,
|
||||
$payload['order_id'] ?? null,
|
||||
$payload['out_trade_no'] ?? null,
|
||||
$payload['trade_no'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $id) {
|
||||
$value = trim((string)$id);
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ class PayOrderService extends BaseService
|
||||
protected PaymentOrderRepository $orderRepository,
|
||||
protected PaymentMethodRepository $methodRepository,
|
||||
protected PluginService $pluginService,
|
||||
protected PaymentStateService $paymentStateService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -168,12 +169,7 @@ class PayOrderService extends BaseService
|
||||
|
||||
// 8. 如果是全额退款则关闭订单
|
||||
if ($refundAmount >= $order->amount) {
|
||||
$this->orderRepository->updateById($order->id, [
|
||||
'status' => PaymentOrder::STATUS_CLOSED,
|
||||
'extra' => array_merge($order->extra ?? [], [
|
||||
'refund_info' => $refundResult,
|
||||
]),
|
||||
]);
|
||||
$this->paymentStateService->closeAfterFullRefund($order, $refundResult);
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -42,6 +42,7 @@ class PayService extends BaseService
|
||||
// 1. 创建订单(幂等)
|
||||
/** @var PaymentOrder $order */
|
||||
$order = $this->payOrderService->createOrder($orderData);
|
||||
$extra = $order->extra ?? [];
|
||||
|
||||
// 2. 查询支付方式
|
||||
$method = $this->methodRepository->find($order->method_id);
|
||||
@@ -50,11 +51,30 @@ class PayService extends BaseService
|
||||
}
|
||||
|
||||
// 3. 通道路由
|
||||
$channel = $this->channelRouterService->chooseChannel(
|
||||
$order->merchant_id,
|
||||
$order->merchant_app_id,
|
||||
$order->method_id
|
||||
);
|
||||
try {
|
||||
$routeDecision = $this->channelRouterService->chooseChannelWithDecision(
|
||||
(int)$order->merchant_id,
|
||||
(int)$order->merchant_app_id,
|
||||
(int)$order->method_id,
|
||||
(float)$order->amount
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$extra['route_error'] = [
|
||||
'message' => $e->getMessage(),
|
||||
'at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
$this->orderRepository->updateById((int)$order->id, ['extra' => $extra]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
/** @var \app\models\PaymentChannel $channel */
|
||||
$channel = $routeDecision['channel'];
|
||||
unset($extra['route_error']);
|
||||
$extra['routing'] = $this->buildRoutingSnapshot($routeDecision, $channel);
|
||||
$this->orderRepository->updateById((int)$order->id, [
|
||||
'channel_id' => (int)$channel->id,
|
||||
'extra' => $extra,
|
||||
]);
|
||||
|
||||
// 4. 实例化插件并初始化(通过插件服务)
|
||||
$plugin = $this->pluginService->getPluginInstance($channel->plugin_code);
|
||||
@@ -85,7 +105,7 @@ class PayService extends BaseService
|
||||
'amount' => $order->amount,
|
||||
'subject' => $order->subject,
|
||||
'body' => $order->body,
|
||||
'extra' => $order->extra ?? [],
|
||||
'extra' => $extra,
|
||||
'_env' => $env,
|
||||
];
|
||||
|
||||
@@ -98,7 +118,6 @@ class PayService extends BaseService
|
||||
$realAmount = round($amount - $fee, 2);
|
||||
|
||||
// 8. 更新订单(通道、支付参数、实际金额)
|
||||
$extra = $order->extra ?? [];
|
||||
$extra['pay_params'] = $payResult['pay_params'] ?? null;
|
||||
$chanOrderNo = $payResult['chan_order_no'] ?? $payResult['channel_order_no'] ?? '';
|
||||
$chanTradeNo = $payResult['chan_trade_no'] ?? $payResult['channel_trade_no'] ?? '';
|
||||
@@ -119,6 +138,35 @@ class PayService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
private function buildRoutingSnapshot(array $routeDecision, \app\models\PaymentChannel $channel): array
|
||||
{
|
||||
$policy = is_array($routeDecision['policy'] ?? null) ? $routeDecision['policy'] : null;
|
||||
$candidates = [];
|
||||
foreach (($routeDecision['candidates'] ?? []) as $candidate) {
|
||||
$candidates[] = [
|
||||
'channel_id' => (int)($candidate['channel_id'] ?? 0),
|
||||
'chan_code' => (string)($candidate['chan_code'] ?? ''),
|
||||
'chan_name' => (string)($candidate['chan_name'] ?? ''),
|
||||
'available' => (bool)($candidate['available'] ?? false),
|
||||
'priority' => (int)($candidate['priority'] ?? 0),
|
||||
'weight' => (int)($candidate['weight'] ?? 0),
|
||||
'role' => (string)($candidate['role'] ?? ''),
|
||||
'reasons' => array_values($candidate['reasons'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'source' => (string)($routeDecision['source'] ?? 'fallback'),
|
||||
'route_mode' => (string)($routeDecision['route_mode'] ?? 'sort'),
|
||||
'policy' => $policy,
|
||||
'selected_channel_id' => (int)$channel->id,
|
||||
'selected_channel_code' => (string)$channel->chan_code,
|
||||
'selected_channel_name' => (string)$channel->chan_name,
|
||||
'candidates' => array_slice($candidates, 0, 10),
|
||||
'selected_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据请求 UA 检测环境
|
||||
*/
|
||||
|
||||
113
app/services/PaymentStateService.php
Normal file
113
app/services/PaymentStateService.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace app\services;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exceptions\BadRequestException;
|
||||
use app\models\PaymentOrder;
|
||||
use app\repositories\PaymentOrderRepository;
|
||||
|
||||
/**
|
||||
* 支付订单状态机服务(最小版)
|
||||
*
|
||||
* 约束核心状态迁移:
|
||||
* - PENDING -> SUCCESS/FAIL/CLOSED
|
||||
* - SUCCESS -> CLOSED
|
||||
*/
|
||||
class PaymentStateService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentOrderRepository $orderRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调支付成功。
|
||||
*
|
||||
* @return bool true=状态有变更, false=幂等无变更
|
||||
*/
|
||||
public function markPaid(PaymentOrder $order, string $chanTradeNo = '', ?string $payAt = null): bool
|
||||
{
|
||||
$from = (int)$order->status;
|
||||
if ($from === PaymentOrder::STATUS_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->canTransit($from, PaymentOrder::STATUS_SUCCESS)) {
|
||||
throw new BadRequestException("illegal status transition: {$from} -> " . PaymentOrder::STATUS_SUCCESS);
|
||||
}
|
||||
|
||||
$ok = $this->orderRepository->updateById((int)$order->id, [
|
||||
'status' => PaymentOrder::STATUS_SUCCESS,
|
||||
'pay_at' => $payAt ?: date('Y-m-d H:i:s'),
|
||||
'chan_trade_no' => $chanTradeNo !== '' ? $chanTradeNo : (string)$order->chan_trade_no,
|
||||
]);
|
||||
|
||||
return (bool)$ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记支付失败(用于已验签的失败回调)。
|
||||
*
|
||||
* @return bool true=状态有变更, false=幂等无变更
|
||||
*/
|
||||
public function markFailed(PaymentOrder $order): bool
|
||||
{
|
||||
$from = (int)$order->status;
|
||||
if ($from === PaymentOrder::STATUS_FAIL) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->canTransit($from, PaymentOrder::STATUS_FAIL)) {
|
||||
throw new BadRequestException("illegal status transition: {$from} -> " . PaymentOrder::STATUS_FAIL);
|
||||
}
|
||||
|
||||
$ok = $this->orderRepository->updateById((int)$order->id, [
|
||||
'status' => PaymentOrder::STATUS_FAIL,
|
||||
]);
|
||||
|
||||
return (bool)$ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全额退款后关单。
|
||||
*
|
||||
* @return bool true=状态有变更, false=幂等无变更
|
||||
*/
|
||||
public function closeAfterFullRefund(PaymentOrder $order, array $refundInfo = []): bool
|
||||
{
|
||||
$from = (int)$order->status;
|
||||
if ($from === PaymentOrder::STATUS_CLOSED) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->canTransit($from, PaymentOrder::STATUS_CLOSED)) {
|
||||
throw new BadRequestException("illegal status transition: {$from} -> " . PaymentOrder::STATUS_CLOSED);
|
||||
}
|
||||
|
||||
$extra = $order->extra ?? [];
|
||||
$extra['refund_info'] = $refundInfo;
|
||||
|
||||
$ok = $this->orderRepository->updateById((int)$order->id, [
|
||||
'status' => PaymentOrder::STATUS_CLOSED,
|
||||
'extra' => $extra,
|
||||
]);
|
||||
|
||||
return (bool)$ok;
|
||||
}
|
||||
|
||||
private function canTransit(int $from, int $to): bool
|
||||
{
|
||||
$allowed = [
|
||||
PaymentOrder::STATUS_PENDING => [
|
||||
PaymentOrder::STATUS_SUCCESS,
|
||||
PaymentOrder::STATUS_FAIL,
|
||||
PaymentOrder::STATUS_CLOSED,
|
||||
],
|
||||
PaymentOrder::STATUS_SUCCESS => [
|
||||
PaymentOrder::STATUS_CLOSED,
|
||||
],
|
||||
PaymentOrder::STATUS_FAIL => [],
|
||||
PaymentOrder::STATUS_CLOSED => [],
|
||||
];
|
||||
|
||||
return in_array($to, $allowed[$from] ?? [], true);
|
||||
}
|
||||
}
|
||||
106
app/services/api/EpayProtocolService.php
Normal file
106
app/services/api/EpayProtocolService.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace app\services\api;
|
||||
|
||||
use app\validation\EpayValidator;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* Epay 协议层服务
|
||||
*
|
||||
* 负责协议参数提取、校验和协议结果映射,不承载支付核心业务。
|
||||
*/
|
||||
class EpayProtocolService
|
||||
{
|
||||
public function __construct(
|
||||
protected EpayService $epayService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 submit.php 请求
|
||||
*
|
||||
* @return array{response_type:string,url?:string,html?:string,form?:array}
|
||||
*/
|
||||
public function handleSubmit(Request $request): array
|
||||
{
|
||||
$data = match ($request->method()) {
|
||||
'GET' => $request->get(),
|
||||
'POST' => $request->post(),
|
||||
default => $request->all(),
|
||||
};
|
||||
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('submit')
|
||||
->validate();
|
||||
|
||||
$result = $this->epayService->submit($params, $request);
|
||||
$payParams = $result['pay_params'] ?? [];
|
||||
|
||||
if (($payParams['type'] ?? '') === 'redirect' && !empty($payParams['url'])) {
|
||||
return [
|
||||
'response_type' => 'redirect',
|
||||
'url' => $payParams['url'],
|
||||
];
|
||||
}
|
||||
|
||||
if (($payParams['type'] ?? '') === 'form') {
|
||||
if (!empty($payParams['html'])) {
|
||||
return [
|
||||
'response_type' => 'form_html',
|
||||
'html' => $payParams['html'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'response_type' => 'form_params',
|
||||
'form' => $payParams,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'response_type' => 'error',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 mapi.php 请求
|
||||
*/
|
||||
public function handleMapi(Request $request): array
|
||||
{
|
||||
$params = EpayValidator::make($request->post())
|
||||
->withScene('mapi')
|
||||
->validate();
|
||||
|
||||
return $this->epayService->mapi($params, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 api.php 请求
|
||||
*/
|
||||
public function handleApi(Request $request): array
|
||||
{
|
||||
$data = array_merge($request->get(), $request->post());
|
||||
$act = strtolower((string)($data['act'] ?? ''));
|
||||
|
||||
if ($act === 'order') {
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('api_order')
|
||||
->validate();
|
||||
return $this->epayService->api($params);
|
||||
}
|
||||
|
||||
if ($act === 'refund') {
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('api_refund')
|
||||
->validate();
|
||||
return $this->epayService->api($params);
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 0,
|
||||
'msg' => '不支持的操作类型',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ class EpayService extends BaseService
|
||||
]);
|
||||
|
||||
return [
|
||||
'code' => 0,
|
||||
'code' => 1,
|
||||
'msg' => '退款成功',
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user