codex基础代码更新

This commit is contained in:
技术老胡
2026-03-20 10:31:13 +08:00
parent 7e545f0621
commit f3919c9899
36 changed files with 5060 additions and 1459 deletions

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

View File

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

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

View File

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

View File

@@ -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 检测环境
*/

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

View 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' => '不支持的操作类型',
];
}
}

View File

@@ -199,7 +199,7 @@ class EpayService extends BaseService
]);
return [
'code' => 0,
'code' => 1,
'msg' => '退款成功',
];
}