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

@@ -6,6 +6,7 @@ namespace app\common\contracts;
use app\exceptions\PaymentException;
use support\Request;
use support\Response;
/**
* 支付插件接口
@@ -81,4 +82,8 @@ interface PaymentInterface
* @throws PaymentException 验签失败、数据异常等
*/
public function notify(Request $request): array;
public function notifySuccess(): string|Response;
public function notifyFail(): string|Response;
}

View File

@@ -9,6 +9,7 @@ use app\common\contracts\PaymentInterface;
use app\exceptions\PaymentException;
use Psr\Http\Message\ResponseInterface;
use support\Request;
use support\Response;
use Yansongda\Pay\Pay;
use Yansongda\Supports\Collection;
@@ -267,4 +268,13 @@ class AlipayPayment extends BasePayment implements PaymentInterface
throw new PaymentException('支付宝回调验签失败:' . $e->getMessage(), 402);
}
}
public function notifySuccess(): string|Response
{
return 'success';
}
public function notifyFail(): string|Response
{
return 'fail';
}
}

View File

@@ -7,6 +7,7 @@ use app\common\base\BasePayment;
use app\common\contracts\PaymentInterface;
use app\exceptions\PaymentException;
use support\Request;
use support\Response;
/**
* 拉卡拉支付插件(最小可用示例)
@@ -74,5 +75,13 @@ class LakalaPayment extends BasePayment implements PaymentInterface
{
throw new PaymentException('LakalaPayment::notify 暂未实现', 402);
}
}
public function notifySuccess(): string|Response
{
return 'success';
}
public function notifyFail(): string|Response
{
return 'fail';
}
}

View File

@@ -3,162 +3,649 @@
namespace app\http\admin\controller;
use app\common\base\BaseController;
use app\repositories\{PaymentChannelRepository, PaymentMethodRepository};
use app\repositories\PaymentChannelRepository;
use app\repositories\PaymentMethodRepository;
use app\repositories\PaymentOrderRepository;
use app\services\ChannelRoutePolicyService;
use app\services\ChannelRouterService;
use app\services\PluginService;
use support\Request;
/**
* 通道管理控制器
*/
class ChannelController extends BaseController
{
public function __construct(
protected PaymentChannelRepository $channelRepository,
protected PaymentMethodRepository $methodRepository,
protected PaymentOrderRepository $orderRepository,
protected PluginService $pluginService,
protected ChannelRoutePolicyService $routePolicyService,
protected ChannelRouterService $channelRouterService,
) {
}
/**
* 通道列表
* GET /adminapi/channel/list
*/
public function list(Request $request)
{
$merchantId = (int)$request->get('merchant_id', 0);
$appId = (int)$request->get('app_id', 0);
$methodCode = trim((string)$request->get('method_code', ''));
$where = [];
if ($merchantId > 0) {
$where['merchant_id'] = $merchantId;
}
if ($appId > 0) {
$where['merchant_app_id'] = $appId;
}
if ($methodCode !== '') {
$method = $this->methodRepository->findByCode($methodCode);
if ($method) {
$where['method_id'] = $method->id;
}
}
$page = (int)($request->get('page', 1));
$pageSize = (int)($request->get('page_size', 10));
$result = $this->channelRepository->paginate($where, $page, $pageSize);
return $this->success($result);
$page = max(1, (int)$request->get('page', 1));
$pageSize = max(1, (int)$request->get('page_size', 10));
$filters = $this->resolveChannelFilters($request, true);
return $this->page($this->channelRepository->searchPaginate($filters, $page, $pageSize));
}
/**
* 通道详情
* GET /adminapi/channel/detail
*/
public function detail(Request $request)
{
$id = (int)$request->get('id', 0);
if (!$id) {
if ($id <= 0) {
return $this->fail('通道ID不能为空', 400);
}
$channel = $this->channelRepository->find($id);
if (!$channel) {
return $this->fail('通道不存在', 404);
}
$methodCode = '';
if ($channel->method_id) {
$method = $this->methodRepository->find($channel->method_id);
$methodCode = $method ? $method->method_code : '';
if ((int)$channel->method_id > 0) {
$method = $this->methodRepository->find((int)$channel->method_id);
$methodCode = $method ? (string)$method->method_code : '';
}
try {
$configSchema = $this->pluginService->getConfigSchema($channel->plugin_code, $methodCode);
// 合并当前配置值
$configSchema = $this->pluginService->getConfigSchema((string)$channel->plugin_code, $methodCode);
$currentConfig = $channel->getConfigArray();
if (isset($configSchema['fields']) && is_array($configSchema['fields'])) {
foreach ($configSchema['fields'] as &$field) {
if (isset($field['field']) && isset($currentConfig[$field['field']])) {
$field['value'] = $currentConfig[$field['field']];
$fieldName = $field['field'] ?? '';
if ($fieldName !== '' && array_key_exists($fieldName, $currentConfig)) {
$field['value'] = $currentConfig[$fieldName];
}
}
unset($field);
}
return $this->success([
'channel' => $channel,
'config_schema' => $configSchema,
]);
} catch (\Throwable $e) {
return $this->success([
'channel' => $channel,
'config_schema' => ['fields' => []],
]);
$configSchema = ['fields' => []];
}
return $this->success([
'channel' => $channel,
'method_code' => $methodCode,
'config_schema' => $configSchema,
]);
}
/**
* 保存通道
* POST /adminapi/channel/save
*/
public function save(Request $request)
{
$data = $request->post();
$id = (int)($data['id'] ?? 0);
$pluginCode = $data['plugin_code'] ?? '';
$methodCode = $data['method_code'] ?? '';
$merchantId = (int)($data['merchant_id'] ?? 0);
$merchantAppId = (int)($data['merchant_app_id'] ?? ($data['app_id'] ?? 0));
$channelCode = trim((string)($data['channel_code'] ?? ($data['chan_code'] ?? '')));
$channelName = trim((string)($data['channel_name'] ?? ($data['chan_name'] ?? '')));
$pluginCode = trim((string)($data['plugin_code'] ?? ''));
$methodCode = trim((string)($data['method_code'] ?? ''));
$enabledProducts = $data['enabled_products'] ?? [];
if (empty($pluginCode) || empty($methodCode)) {
return $this->fail('插件编码和支付方式不能为空', 400);
if ($merchantId <= 0) {
return $this->fail('请选择所属商户', 400);
}
// 提取配置参数(从表单字段中提取)
if ($merchantAppId <= 0) {
return $this->fail('请选择所属应用', 400);
}
if ($channelName === '') {
return $this->fail('请输入通道名称', 400);
}
if ($pluginCode === '' || $methodCode === '') {
return $this->fail('支付插件和支付方式不能为空', 400);
}
$method = $this->methodRepository->findAnyByCode($methodCode);
if (!$method) {
return $this->fail('支付方式不存在', 400);
}
if ($channelCode !== '') {
$exists = $this->channelRepository->findByChanCode($channelCode);
if ($exists && (int)$exists->id !== $id) {
return $this->fail('通道编码已存在', 400);
}
}
try {
$configJson = $this->pluginService->buildConfigFromForm($pluginCode, $methodCode, $data);
} catch (\Throwable $e) {
return $this->fail('插件不存在或配置错误:' . $e->getMessage(), 400);
}
$method = $this->methodRepository->findByCode($methodCode);
if (!$method) {
return $this->fail('支付方式不存在', 400);
}
$configWithProducts = array_merge($configJson, ['enabled_products' => is_array($enabledProducts) ? $enabledProducts : []]);
$channelData = [
'merchant_id' => (int)($data['merchant_id'] ?? 0),
'merchant_app_id' => (int)($data['app_id'] ?? 0),
'chan_code' => $data['channel_code'] ?? $data['chan_code'] ?? '',
'chan_name' => $data['channel_name'] ?? $data['chan_name'] ?? '',
'merchant_id' => $merchantId,
'merchant_app_id' => $merchantAppId,
'chan_code' => $channelCode !== '' ? $channelCode : 'CH' . date('YmdHis') . mt_rand(1000, 9999),
'chan_name' => $channelName,
'plugin_code' => $pluginCode,
'method_id' => $method->id,
'config_json' => $configWithProducts,
'split_ratio' => isset($data['split_ratio']) ? (float)$data['split_ratio'] : 100.00,
'chan_cost' => isset($data['channel_cost']) ? (float)$data['channel_cost'] : 0.00,
'chan_mode' => $data['channel_mode'] ?? 'wallet',
'daily_limit' => isset($data['daily_limit']) ? (float)$data['daily_limit'] : 0.00,
'method_id' => (int)$method->id,
'config_json' => array_merge($configJson, [
'enabled_products' => is_array($enabledProducts) ? array_values($enabledProducts) : [],
]),
'split_ratio' => isset($data['split_ratio']) ? (float)$data['split_ratio'] : 100,
'chan_cost' => isset($data['channel_cost']) ? (float)$data['channel_cost'] : 0,
'chan_mode' => trim((string)($data['channel_mode'] ?? 'wallet')) ?: 'wallet',
'daily_limit' => isset($data['daily_limit']) ? (float)$data['daily_limit'] : 0,
'daily_cnt' => isset($data['daily_count']) ? (int)$data['daily_count'] : 0,
'min_amount' => isset($data['min_amount']) && $data['min_amount'] !== '' ? (float)$data['min_amount'] : null,
'max_amount' => isset($data['max_amount']) && $data['max_amount'] !== '' ? (float)$data['max_amount'] : null,
'status' => (int)($data['status'] ?? 1),
'sort' => (int)($data['sort'] ?? 0),
];
if ($id > 0) {
// 更新
$channel = $this->channelRepository->find($id);
if (!$channel) {
return $this->fail('通道不存在', 404);
}
$this->channelRepository->updateById($id, $channelData);
} else {
if (empty($channelData['chan_code'])) {
$channelData['chan_code'] = 'CH' . date('YmdHis') . mt_rand(1000, 9999);
}
$this->channelRepository->create($channelData);
$channel = $this->channelRepository->create($channelData);
$id = (int)$channel->id;
}
return $this->success(null, '保存成功');
}
}
return $this->success(['id' => $id], '保存成功');
}
public function toggle(Request $request)
{
$id = (int)$request->post('id', 0);
$status = $request->post('status', null);
if ($id <= 0 || $status === null) {
return $this->fail('参数错误', 400);
}
$ok = $this->channelRepository->updateById($id, ['status' => (int)$status]);
return $ok ? $this->success(null, '操作成功') : $this->fail('操作失败', 500);
}
public function monitor(Request $request)
{
$filters = $this->resolveChannelFilters($request);
$days = $this->resolveDays($request->get('days', 7));
$channels = $this->channelRepository->searchList($filters);
if ($channels->isEmpty()) {
return $this->success(['list' => [], 'summary' => $this->buildMonitorSummary([])]);
}
$orderFilters = [
'merchant_id' => $filters['merchant_id'] ?? null,
'merchant_app_id' => $filters['merchant_app_id'] ?? null,
'method_id' => $filters['method_id'] ?? null,
'created_from' => $days['created_from'],
'created_to' => $days['created_to'],
];
$channelIds = [];
foreach ($channels as $channel) {
$channelIds[] = (int)$channel->id;
}
$statsMap = $this->orderRepository->aggregateByChannel($channelIds, $orderFilters);
$rows = [];
foreach ($channels as $channel) {
$rows[] = $this->buildMonitorRow($channel->toArray(), $statsMap[(int)$channel->id] ?? []);
}
usort($rows, function (array $left, array $right) {
if (($right['health_score'] ?? 0) === ($left['health_score'] ?? 0)) {
return ($left['sort'] ?? 0) <=> ($right['sort'] ?? 0);
}
return ($right['health_score'] ?? 0) <=> ($left['health_score'] ?? 0);
});
return $this->success(['list' => $rows, 'summary' => $this->buildMonitorSummary($rows)]);
}
public function polling(Request $request)
{
$filters = $this->resolveChannelFilters($request);
$days = $this->resolveDays($request->get('days', 7));
$channels = $this->channelRepository->searchList($filters);
$testAmount = $request->get('test_amount', null);
$testAmount = ($testAmount === null || $testAmount === '') ? null : (float)$testAmount;
if ($channels->isEmpty()) {
return $this->success(['list' => [], 'summary' => $this->buildPollingSummary([])]);
}
$orderFilters = [
'merchant_id' => $filters['merchant_id'] ?? null,
'merchant_app_id' => $filters['merchant_app_id'] ?? null,
'method_id' => $filters['method_id'] ?? null,
'created_from' => $days['created_from'],
'created_to' => $days['created_to'],
];
$channelIds = [];
foreach ($channels as $channel) {
$channelIds[] = (int)$channel->id;
}
$statsMap = $this->orderRepository->aggregateByChannel($channelIds, $orderFilters);
$rows = [];
foreach ($channels as $channel) {
$monitorRow = $this->buildMonitorRow($channel->toArray(), $statsMap[(int)$channel->id] ?? []);
$rows[] = $this->buildPollingRow($monitorRow, $testAmount);
}
$stateWeight = ['ready' => 0, 'degraded' => 1, 'blocked' => 2];
usort($rows, function (array $left, array $right) use ($stateWeight) {
$leftWeight = $stateWeight[$left['route_state'] ?? 'blocked'] ?? 9;
$rightWeight = $stateWeight[$right['route_state'] ?? 'blocked'] ?? 9;
if ($leftWeight === $rightWeight) {
if (($right['route_score'] ?? 0) === ($left['route_score'] ?? 0)) {
return ($left['sort'] ?? 0) <=> ($right['sort'] ?? 0);
}
return ($right['route_score'] ?? 0) <=> ($left['route_score'] ?? 0);
}
return $leftWeight <=> $rightWeight;
});
foreach ($rows as $index => &$row) {
$row['route_rank'] = $index + 1;
}
unset($row);
return $this->success(['list' => $rows, 'summary' => $this->buildPollingSummary($rows)]);
}
public function policyList(Request $request)
{
$merchantId = (int)$request->get('merchant_id', 0);
$merchantAppId = (int)$request->get('merchant_app_id', $request->get('app_id', 0));
$methodCode = trim((string)$request->get('method_code', ''));
$pluginCode = trim((string)$request->get('plugin_code', ''));
$status = $request->get('status', null);
$policies = $this->routePolicyService->list();
$channelMap = [];
foreach ($this->channelRepository->searchList([]) as $channel) {
$channelMap[(int)$channel->id] = $channel->toArray();
}
$filtered = array_values(array_filter($policies, function (array $policy) use ($merchantId, $merchantAppId, $methodCode, $pluginCode, $status) {
if ($merchantId > 0 && (int)($policy['merchant_id'] ?? 0) !== $merchantId) return false;
if ($merchantAppId > 0 && (int)($policy['merchant_app_id'] ?? 0) !== $merchantAppId) return false;
if ($methodCode !== '' && (string)($policy['method_code'] ?? '') !== $methodCode) return false;
if ($pluginCode !== '' && (string)($policy['plugin_code'] ?? '') !== $pluginCode) return false;
if ($status !== null && $status !== '' && (int)($policy['status'] ?? 0) !== (int)$status) return false;
return true;
}));
$list = [];
foreach ($filtered as $policy) {
$items = [];
foreach (($policy['items'] ?? []) as $index => $item) {
$channelId = (int)($item['channel_id'] ?? 0);
$channel = $channelMap[$channelId] ?? [];
$items[] = [
'channel_id' => $channelId,
'role' => trim((string)($item['role'] ?? ($index === 0 ? 'primary' : 'backup'))),
'weight' => max(0, (int)($item['weight'] ?? 100)),
'priority' => max(1, (int)($item['priority'] ?? ($index + 1))),
'chan_code' => (string)($channel['chan_code'] ?? ''),
'chan_name' => (string)($channel['chan_name'] ?? ''),
'channel_status' => isset($channel['status']) ? (int)$channel['status'] : null,
'sort' => (int)($channel['sort'] ?? 0),
'plugin_code' => (string)($channel['plugin_code'] ?? ''),
'method_id' => (int)($channel['method_id'] ?? 0),
'merchant_id' => (int)($channel['merchant_id'] ?? 0),
'merchant_app_id' => (int)($channel['merchant_app_id'] ?? 0),
];
}
usort($items, fn(array $left, array $right) => ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0));
$policy['items'] = $items;
$policy['channel_count'] = count($items);
$list[] = $policy;
}
return $this->success([
'list' => $list,
'summary' => [
'total' => count($list),
'enabled' => count(array_filter($list, fn(array $policy) => (int)($policy['status'] ?? 0) === 1)),
],
]);
}
public function policySave(Request $request)
{
try {
$payload = $this->preparePolicyPayload($request->post(), true);
return $this->success($this->routePolicyService->save($payload), '保存成功');
} catch (\InvalidArgumentException $e) {
return $this->fail($e->getMessage(), 400);
}
}
public function policyPreview(Request $request)
{
try {
$payload = $this->preparePolicyPayload($request->post(), false);
$testAmount = $request->post('test_amount', $request->post('preview_amount', 0));
$amount = ($testAmount === null || $testAmount === '') ? 0 : (float)$testAmount;
$preview = $this->channelRouterService->previewPolicyDraft(
(int)$payload['merchant_id'],
(int)$payload['merchant_app_id'],
(int)$payload['method_id'],
$payload,
$amount
);
return $this->success($preview);
} catch (\Throwable $e) {
return $this->fail($e->getMessage(), 400);
}
}
public function policyDelete(Request $request)
{
$id = trim((string)$request->post('id', ''));
if ($id === '') {
return $this->fail('策略ID不能为空', 400);
}
$ok = $this->routePolicyService->delete($id);
return $ok ? $this->success(null, '删除成功') : $this->fail('策略不存在或已删除', 404);
}
private function preparePolicyPayload(array $data, bool $requirePolicyName = true): array
{
$policyName = trim((string)($data['policy_name'] ?? ''));
$merchantId = (int)($data['merchant_id'] ?? 0);
$merchantAppId = (int)($data['merchant_app_id'] ?? ($data['app_id'] ?? 0));
$methodCode = trim((string)($data['method_code'] ?? ''));
$pluginCode = trim((string)($data['plugin_code'] ?? ''));
$routeMode = trim((string)($data['route_mode'] ?? 'priority'));
$status = (int)($data['status'] ?? 1);
$itemsInput = $data['items'] ?? [];
if ($requirePolicyName && $policyName === '') throw new \InvalidArgumentException('请输入策略名称');
if ($methodCode === '') throw new \InvalidArgumentException('请选择支付方式');
if (!in_array($routeMode, ['priority', 'weight', 'failover'], true)) throw new \InvalidArgumentException('路由模式不合法');
if (!is_array($itemsInput) || $itemsInput === []) throw new \InvalidArgumentException('请至少选择一个通道');
if ($merchantId <= 0 || $merchantAppId <= 0) throw new \InvalidArgumentException('请先选择商户和应用');
$method = $this->methodRepository->findAnyByCode($methodCode);
if (!$method) throw new \InvalidArgumentException('支付方式不存在');
$channelMap = [];
foreach ($this->channelRepository->searchList([]) as $channel) {
$channelMap[(int)$channel->id] = $channel->toArray();
}
$normalizedItems = [];
$usedChannelIds = [];
foreach ($itemsInput as $index => $item) {
$channelId = (int)($item['channel_id'] ?? 0);
if ($channelId <= 0) throw new \InvalidArgumentException('策略项中的通道ID不合法');
if (in_array($channelId, $usedChannelIds, true)) throw new \InvalidArgumentException('策略中存在重复通道,请去重后再提交');
$channel = $channelMap[$channelId] ?? null;
if (!$channel) throw new \InvalidArgumentException('存在未找到的通道,请刷新后重试');
if ($merchantId > 0 && (int)$channel['merchant_id'] !== $merchantId) throw new \InvalidArgumentException('策略中的通道与商户不匹配');
if ($merchantAppId > 0 && (int)$channel['merchant_app_id'] !== $merchantAppId) throw new \InvalidArgumentException('策略中的通道与应用不匹配');
if ((int)$channel['method_id'] !== (int)$method->id) throw new \InvalidArgumentException('策略中的通道与支付方式不匹配');
if ($pluginCode !== '' && (string)$channel['plugin_code'] !== $pluginCode) throw new \InvalidArgumentException('策略中的通道与插件不匹配');
$defaultRole = $routeMode === 'weight' ? 'normal' : ($index === 0 ? 'primary' : 'backup');
$role = trim((string)($item['role'] ?? $defaultRole));
if (!in_array($role, ['primary', 'backup', 'normal'], true)) {
$role = $defaultRole;
}
$normalizedItems[] = [
'channel_id' => $channelId,
'role' => $role,
'weight' => max(0, (int)($item['weight'] ?? 100)),
'priority' => max(1, (int)($item['priority'] ?? ($index + 1))),
];
$usedChannelIds[] = $channelId;
}
usort($normalizedItems, function (array $left, array $right) {
if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) {
return ($right['weight'] ?? 0) <=> ($left['weight'] ?? 0);
}
return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0);
});
foreach ($normalizedItems as $index => &$item) {
$item['priority'] = $index + 1;
if ($routeMode === 'weight' && $item['role'] === 'backup') {
$item['role'] = 'normal';
}
}
unset($item);
return [
'id' => trim((string)($data['id'] ?? '')),
'policy_name' => $policyName !== '' ? $policyName : '策略草稿预览',
'merchant_id' => $merchantId,
'merchant_app_id' => $merchantAppId,
'method_code' => $methodCode,
'method_id' => (int)$method->id,
'plugin_code' => $pluginCode,
'route_mode' => $routeMode,
'status' => $status,
'circuit_breaker_threshold' => max(0, min(100, (int)($data['circuit_breaker_threshold'] ?? 50))),
'failover_cooldown' => max(0, (int)($data['failover_cooldown'] ?? 10)),
'remark' => trim((string)($data['remark'] ?? '')),
'items' => $normalizedItems,
];
}
private function resolveChannelFilters(Request $request, bool $withKeywords = false): array
{
$filters = [];
$merchantId = (int)$request->get('merchant_id', 0);
if ($merchantId > 0) $filters['merchant_id'] = $merchantId;
$merchantAppId = (int)$request->get('merchant_app_id', $request->get('app_id', 0));
if ($merchantAppId > 0) $filters['merchant_app_id'] = $merchantAppId;
$methodCode = trim((string)$request->get('method_code', ''));
if ($methodCode !== '') {
$method = $this->methodRepository->findAnyByCode($methodCode);
$filters['method_id'] = $method ? (int)$method->id : -1;
}
$pluginCode = trim((string)$request->get('plugin_code', ''));
if ($pluginCode !== '') $filters['plugin_code'] = $pluginCode;
$status = $request->get('status', null);
if ($status !== null && $status !== '') $filters['status'] = (int)$status;
if ($withKeywords) {
$chanCode = trim((string)$request->get('chan_code', ''));
if ($chanCode !== '') $filters['chan_code'] = $chanCode;
$chanName = trim((string)$request->get('chan_name', ''));
if ($chanName !== '') $filters['chan_name'] = $chanName;
}
return $filters;
}
private function resolveDays(mixed $daysInput): array
{
$days = max(1, min(30, (int)$daysInput));
return [
'days' => $days,
'created_from' => date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')),
'created_to' => date('Y-m-d H:i:s'),
];
}
private function buildMonitorRow(array $channel, array $stats): array
{
$totalOrders = (int)($stats['total_orders'] ?? 0);
$successOrders = (int)($stats['success_orders'] ?? 0);
$pendingOrders = (int)($stats['pending_orders'] ?? 0);
$failOrders = (int)($stats['fail_orders'] ?? 0);
$closedOrders = (int)($stats['closed_orders'] ?? 0);
$todayOrders = (int)($stats['today_orders'] ?? 0);
$todaySuccessOrders = (int)($stats['today_success_orders'] ?? 0);
$todaySuccessAmount = round((float)($stats['today_success_amount'] ?? 0), 2);
$successRate = $totalOrders > 0 ? round($successOrders / $totalOrders * 100, 2) : 0;
$dailyLimit = isset($channel['daily_limit']) ? (float)$channel['daily_limit'] : 0;
$dailyCnt = isset($channel['daily_cnt']) ? (int)$channel['daily_cnt'] : 0;
$todayLimitUsageRate = $dailyLimit > 0 ? round(min(100, ($todaySuccessAmount / $dailyLimit) * 100), 2) : null;
$healthScore = 0;
$healthLevel = 'disabled';
$status = (int)($channel['status'] ?? 0);
if ($status === 1) {
if ($totalOrders === 0) {
$healthScore = 60;
$healthLevel = 'idle';
} else {
$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;
$healthScore = max(0, min(100, $healthScore));
if ($healthScore >= 80) $healthLevel = 'healthy';
elseif ($healthScore >= 60) $healthLevel = 'warning';
else $healthLevel = 'danger';
}
}
return [
'id' => (int)($channel['id'] ?? 0),
'merchant_id' => (int)($channel['merchant_id'] ?? 0),
'merchant_app_id' => (int)($channel['merchant_app_id'] ?? 0),
'chan_code' => (string)($channel['chan_code'] ?? ''),
'chan_name' => (string)($channel['chan_name'] ?? ''),
'plugin_code' => (string)($channel['plugin_code'] ?? ''),
'method_id' => (int)($channel['method_id'] ?? 0),
'status' => $status,
'sort' => (int)($channel['sort'] ?? 0),
'daily_limit' => $dailyLimit > 0 ? round($dailyLimit, 2) : 0,
'daily_cnt' => $dailyCnt > 0 ? $dailyCnt : 0,
'min_amount' => $channel['min_amount'] === null ? null : round((float)$channel['min_amount'], 2),
'max_amount' => $channel['max_amount'] === null ? null : round((float)$channel['max_amount'], 2),
'total_orders' => $totalOrders,
'success_orders' => $successOrders,
'pending_orders' => $pendingOrders,
'fail_orders' => $failOrders,
'closed_orders' => $closedOrders,
'today_orders' => $todayOrders,
'today_success_orders' => $todaySuccessOrders,
'total_amount' => round((float)($stats['total_amount'] ?? 0), 2),
'success_amount' => round((float)($stats['success_amount'] ?? 0), 2),
'today_amount' => round((float)($stats['today_amount'] ?? 0), 2),
'today_success_amount' => $todaySuccessAmount,
'last_order_at' => $stats['last_order_at'] ?? null,
'last_success_at' => $stats['last_success_at'] ?? null,
'success_rate' => $successRate,
'today_limit_usage_rate' => $todayLimitUsageRate,
'health_score' => $healthScore,
'health_level' => $healthLevel,
];
}
private function buildMonitorSummary(array $rows): array
{
$summary = [
'total_channels' => count($rows),
'enabled_channels' => 0,
'healthy_channels' => 0,
'warning_channels' => 0,
'danger_channels' => 0,
'total_orders' => 0,
'success_rate' => 0,
'today_success_amount' => 0,
];
$successOrders = 0;
foreach ($rows as $row) {
if ((int)($row['status'] ?? 0) === 1) $summary['enabled_channels']++;
$level = $row['health_level'] ?? '';
if ($level === 'healthy') $summary['healthy_channels']++;
elseif ($level === 'warning') $summary['warning_channels']++;
elseif ($level === 'danger') $summary['danger_channels']++;
$summary['total_orders'] += (int)($row['total_orders'] ?? 0);
$summary['today_success_amount'] = round($summary['today_success_amount'] + (float)($row['today_success_amount'] ?? 0), 2);
$successOrders += (int)($row['success_orders'] ?? 0);
}
if ($summary['total_orders'] > 0) {
$summary['success_rate'] = round($successOrders / $summary['total_orders'] * 100, 2);
}
return $summary;
}
private function buildPollingRow(array $monitorRow, ?float $testAmount): array
{
$reasons = [];
$status = (int)($monitorRow['status'] ?? 0);
$dailyLimit = (float)($monitorRow['daily_limit'] ?? 0);
$dailyCnt = (int)($monitorRow['daily_cnt'] ?? 0);
$todaySuccessAmount = (float)($monitorRow['today_success_amount'] ?? 0);
$todayOrders = (int)($monitorRow['today_orders'] ?? 0);
$minAmount = $monitorRow['min_amount'];
$maxAmount = $monitorRow['max_amount'];
$remainingDailyLimit = $dailyLimit > 0 ? round($dailyLimit - $todaySuccessAmount, 2) : null;
$remainingDailyCount = $dailyCnt > 0 ? $dailyCnt - $todayOrders : null;
$routeState = 'ready';
if ($status !== 1) { $routeState = 'blocked'; $reasons[] = '通道已禁用'; }
if ($testAmount !== null) {
if ($minAmount !== null && $testAmount < (float)$minAmount) { $routeState = 'blocked'; $reasons[] = '低于最小支付金额'; }
if ($maxAmount !== null && (float)$maxAmount > 0 && $testAmount > (float)$maxAmount) { $routeState = 'blocked'; $reasons[] = '超过最大支付金额'; }
}
if ($remainingDailyLimit !== null && $remainingDailyLimit <= 0) { $routeState = 'blocked'; $reasons[] = '单日限额已用尽'; }
if ($remainingDailyCount !== null && $remainingDailyCount <= 0) { $routeState = 'blocked'; $reasons[] = '单日笔数已用尽'; }
if ($routeState !== 'blocked') {
if (($monitorRow['health_level'] ?? '') === 'warning' || ($monitorRow['health_level'] ?? '') === 'danger') { $routeState = 'degraded'; $reasons[] = '监控健康度偏低'; }
if ((int)($monitorRow['total_orders'] ?? 0) === 0) { $routeState = 'degraded'; $reasons[] = '暂无订单样本,建议灰度'; }
if ((float)($monitorRow['success_rate'] ?? 0) < 80 && (int)($monitorRow['total_orders'] ?? 0) > 0) { $routeState = 'degraded'; $reasons[] = '成功率偏低'; }
if ((int)($monitorRow['pending_orders'] ?? 0) > max(3, (int)($monitorRow['success_orders'] ?? 0))) { $routeState = 'degraded'; $reasons[] = '待支付订单偏多'; }
}
$priorityBonus = max(0, 20 - min(20, (int)($monitorRow['sort'] ?? 0) * 2));
$sampleBonus = (int)($monitorRow['total_orders'] ?? 0) > 0 ? min(10, (int)floor(((float)($monitorRow['success_rate'] ?? 0)) / 10)) : 5;
$routeScore = round(max(0, min(100, ((float)($monitorRow['health_score'] ?? 0) * 0.7) + $priorityBonus + $sampleBonus)), 2);
if ($routeState === 'degraded') $routeScore = max(0, round($routeScore - 15, 2));
if ($routeState === 'blocked') $routeScore = 0;
return array_merge($monitorRow, [
'route_state' => $routeState,
'route_rank' => 0,
'route_score' => $routeScore,
'remaining_daily_limit' => $remainingDailyLimit === null ? null : round(max(0, $remainingDailyLimit), 2),
'remaining_daily_count' => $remainingDailyCount === null ? null : max(0, $remainingDailyCount),
'reasons' => array_values(array_unique($reasons)),
]);
}
private function buildPollingSummary(array $rows): array
{
$summary = [
'total_channels' => count($rows),
'ready_channels' => 0,
'degraded_channels' => 0,
'blocked_channels' => 0,
'recommended_channel' => null,
'fallback_chain' => [],
];
foreach ($rows as $row) {
$state = $row['route_state'] ?? 'blocked';
if ($state === 'ready') $summary['ready_channels']++;
elseif ($state === 'degraded') $summary['degraded_channels']++;
else $summary['blocked_channels']++;
}
foreach ($rows as $row) {
if ($summary['recommended_channel'] === null && ($row['route_state'] ?? '') !== 'blocked') {
$summary['recommended_channel'] = $row;
continue;
}
if (($row['route_state'] ?? '') !== 'blocked' && count($summary['fallback_chain']) < 5) {
$summary['fallback_chain'][] = sprintf('%s%s', (string)($row['chan_name'] ?? ''), (string)($row['chan_code'] ?? ''));
}
}
if ($summary['recommended_channel'] !== null) {
$recommendedId = (int)($summary['recommended_channel']['id'] ?? 0);
if ($recommendedId > 0) {
$summary['fallback_chain'] = [];
foreach ($rows as $row) {
if ((int)($row['id'] ?? 0) === $recommendedId || ($row['route_state'] ?? '') === 'blocked') continue;
$summary['fallback_chain'][] = sprintf('%s%s', (string)($row['chan_name'] ?? ''), (string)($row['chan_code'] ?? ''));
if (count($summary['fallback_chain']) >= 5) break;
}
}
}
return $summary;
}
}

View File

@@ -0,0 +1,522 @@
<?php
namespace app\http\admin\controller;
use app\common\base\BaseController;
use app\repositories\PaymentMethodRepository;
use support\Db;
use support\Request;
class FinanceController extends BaseController
{
public function __construct(
protected PaymentMethodRepository $methodRepository,
) {
}
public function reconciliation(Request $request)
{
$page = (int)$request->get('page', 1);
$pageSize = (int)$request->get('page_size', 10);
$filters = $this->buildFilters($request);
$baseQuery = $this->buildOrderQuery($filters);
$summaryRow = (clone $baseQuery)
->selectRaw(
'COUNT(*) AS total_orders,
SUM(CASE WHEN o.status = 1 THEN 1 ELSE 0 END) AS success_orders,
SUM(CASE WHEN o.status = 0 THEN 1 ELSE 0 END) AS pending_orders,
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders,
COALESCE(SUM(o.amount), 0) AS total_amount,
COALESCE(SUM(o.fee), 0) AS total_fee,
COALESCE(SUM(o.real_amount - o.fee), 0) AS total_net_amount'
)
->first();
$paginator = (clone $baseQuery)
->selectRaw(
"o.*, m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name, pm.method_code, pm.method_name,
pc.chan_code, pc.chan_name,
COALESCE(o.real_amount - o.fee, 0) AS net_amount,
JSON_UNQUOTE(JSON_EXTRACT(o.extra, '$.routing.policy.policy_name')) AS route_policy_name"
)
->orderByDesc('o.id')
->paginate($pageSize, ['*'], 'page', $page);
$items = [];
foreach ($paginator->items() as $row) {
$item = (array)$row;
$item['reconcile_status'] = $this->reconcileStatus((int)($item['status'] ?? 0), (int)($item['notify_stat'] ?? 0));
$item['reconcile_status_text'] = $this->reconcileStatusText($item['reconcile_status']);
$items[] = $item;
}
return $this->success([
'summary' => [
'total_orders' => (int)($summaryRow->total_orders ?? 0),
'success_orders' => (int)($summaryRow->success_orders ?? 0),
'pending_orders' => (int)($summaryRow->pending_orders ?? 0),
'notify_pending_orders' => (int)($summaryRow->notify_pending_orders ?? 0),
'total_amount' => (string)($summaryRow->total_amount ?? '0.00'),
'total_fee' => (string)($summaryRow->total_fee ?? '0.00'),
'total_net_amount' => (string)($summaryRow->total_net_amount ?? '0.00'),
],
'list' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
]);
}
public function settlement(Request $request)
{
$page = (int)$request->get('page', 1);
$pageSize = (int)$request->get('page_size', 10);
$filters = $this->buildFilters($request);
$baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1);
$summaryRow = (clone $baseQuery)
->selectRaw(
'COUNT(DISTINCT o.merchant_id) AS merchant_count,
COUNT(DISTINCT o.merchant_app_id) AS app_count,
COUNT(*) AS success_orders,
COALESCE(SUM(o.real_amount), 0) AS gross_amount,
COALESCE(SUM(o.fee), 0) AS fee_amount,
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders,
COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS notify_pending_amount'
)
->first();
$paginator = (clone $baseQuery)
->selectRaw(
'o.merchant_id, o.merchant_app_id,
m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name,
COUNT(*) AS success_orders,
COUNT(DISTINCT o.channel_id) AS channel_count,
COUNT(DISTINCT o.method_id) AS method_count,
COALESCE(SUM(o.real_amount), 0) AS gross_amount,
COALESCE(SUM(o.fee), 0) AS fee_amount,
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders,
COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS notify_pending_amount,
MAX(o.pay_at) AS last_pay_at'
)
->groupBy('o.merchant_id', 'o.merchant_app_id', 'm.merchant_no', 'm.merchant_name', 'ma.app_id', 'ma.app_name')
->orderByRaw('SUM(o.real_amount - o.fee) DESC')
->paginate($pageSize, ['*'], 'page', $page);
return $this->success([
'summary' => [
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
'app_count' => (int)($summaryRow->app_count ?? 0),
'success_orders' => (int)($summaryRow->success_orders ?? 0),
'gross_amount' => (string)($summaryRow->gross_amount ?? '0.00'),
'fee_amount' => (string)($summaryRow->fee_amount ?? '0.00'),
'net_amount' => (string)($summaryRow->net_amount ?? '0.00'),
'notify_pending_orders' => (int)($summaryRow->notify_pending_orders ?? 0),
'notify_pending_amount' => (string)($summaryRow->notify_pending_amount ?? '0.00'),
],
'list' => array_map(fn ($row) => (array)$row, $paginator->items()),
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
]);
}
public function fee(Request $request)
{
$page = (int)$request->get('page', 1);
$pageSize = (int)$request->get('page_size', 10);
$filters = $this->buildFilters($request);
$baseQuery = $this->buildOrderQuery($filters);
if (($filters['status'] ?? '') === '') {
$baseQuery->where('o.status', 1);
}
$summaryRow = (clone $baseQuery)
->selectRaw(
'COUNT(DISTINCT o.merchant_id) AS merchant_count,
COUNT(DISTINCT o.channel_id) AS channel_count,
COUNT(DISTINCT o.method_id) AS method_count,
COUNT(*) AS order_count,
COALESCE(SUM(o.real_amount), 0) AS total_amount,
COALESCE(SUM(o.fee), 0) AS total_fee'
)
->first();
$paginator = (clone $baseQuery)
->selectRaw(
'o.merchant_id, o.channel_id, o.method_id,
m.merchant_no, m.merchant_name,
pm.method_code, pm.method_name,
pc.chan_code, pc.chan_name,
COUNT(*) AS order_count,
SUM(CASE WHEN o.status = 1 THEN 1 ELSE 0 END) AS success_orders,
COALESCE(SUM(o.real_amount), 0) AS total_amount,
COALESCE(SUM(o.fee), 0) AS total_fee,
COALESCE(AVG(CASE WHEN o.real_amount > 0 THEN o.fee / o.real_amount ELSE NULL END), 0) AS avg_fee_rate,
MAX(o.created_at) AS last_order_at'
)
->groupBy('o.merchant_id', 'o.channel_id', 'o.method_id', 'm.merchant_no', 'm.merchant_name', 'pm.method_code', 'pm.method_name', 'pc.chan_code', 'pc.chan_name')
->orderByRaw('SUM(o.fee) DESC')
->paginate($pageSize, ['*'], 'page', $page);
$items = [];
foreach ($paginator->items() as $row) {
$item = (array)$row;
$item['avg_fee_rate_percent'] = round(((float)($item['avg_fee_rate'] ?? 0)) * 100, 4);
$items[] = $item;
}
return $this->success([
'summary' => [
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
'channel_count' => (int)($summaryRow->channel_count ?? 0),
'method_count' => (int)($summaryRow->method_count ?? 0),
'order_count' => (int)($summaryRow->order_count ?? 0),
'total_amount' => (string)($summaryRow->total_amount ?? '0.00'),
'total_fee' => (string)($summaryRow->total_fee ?? '0.00'),
],
'list' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
]);
}
public function settlementRecord(Request $request)
{
$page = (int)$request->get('page', 1);
$pageSize = (int)$request->get('page_size', 10);
$filters = $this->buildFilters($request);
$baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1);
$summaryRow = (clone $baseQuery)
->selectRaw(
"COUNT(DISTINCT CONCAT(o.merchant_app_id, '#', DATE(COALESCE(o.pay_at, o.created_at)))) AS record_count,
COUNT(DISTINCT o.merchant_id) AS merchant_count,
COUNT(DISTINCT o.merchant_app_id) AS app_count,
COUNT(*) AS success_orders,
COALESCE(SUM(o.real_amount), 0) AS gross_amount,
COALESCE(SUM(o.fee), 0) AS fee_amount,
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders"
)
->first();
$paginator = (clone $baseQuery)
->selectRaw(
"DATE(COALESCE(o.pay_at, o.created_at)) AS settlement_date,
o.merchant_id, o.merchant_app_id,
m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name,
COUNT(*) AS success_orders,
COALESCE(SUM(o.real_amount), 0) AS gross_amount,
COALESCE(SUM(o.fee), 0) AS fee_amount,
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders,
MAX(o.pay_at) AS last_pay_at"
)
->groupByRaw("DATE(COALESCE(o.pay_at, o.created_at)), o.merchant_id, o.merchant_app_id, m.merchant_no, m.merchant_name, ma.app_id, ma.app_name")
->orderByDesc('settlement_date')
->orderByRaw('SUM(o.real_amount - o.fee) DESC')
->paginate($pageSize, ['*'], 'page', $page);
$items = [];
foreach ($paginator->items() as $row) {
$item = (array)$row;
$item['settlement_status'] = (int)($item['notify_pending_orders'] ?? 0) > 0 ? 'pending' : 'ready';
$item['settlement_status_text'] = $item['settlement_status'] === 'ready' ? 'ready' : 'pending_notify';
$items[] = $item;
}
return $this->success([
'summary' => [
'record_count' => (int)($summaryRow->record_count ?? 0),
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
'app_count' => (int)($summaryRow->app_count ?? 0),
'success_orders' => (int)($summaryRow->success_orders ?? 0),
'gross_amount' => (string)($summaryRow->gross_amount ?? '0.00'),
'fee_amount' => (string)($summaryRow->fee_amount ?? '0.00'),
'net_amount' => (string)($summaryRow->net_amount ?? '0.00'),
'notify_pending_orders' => (int)($summaryRow->notify_pending_orders ?? 0),
],
'list' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
]);
}
public function batchSettlement(Request $request)
{
$page = (int)$request->get('page', 1);
$pageSize = (int)$request->get('page_size', 10);
$filters = $this->buildFilters($request);
$baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1);
$summaryRow = (clone $baseQuery)
->selectRaw(
"COUNT(DISTINCT o.merchant_id) AS merchant_count,
COUNT(DISTINCT o.merchant_app_id) AS app_count,
COUNT(*) AS success_orders,
COUNT(DISTINCT CONCAT(o.merchant_app_id, '#', DATE(COALESCE(o.pay_at, o.created_at)))) AS batch_days,
COALESCE(SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS ready_amount,
COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS pending_amount"
)
->first();
$paginator = (clone $baseQuery)
->selectRaw(
"o.merchant_id, o.merchant_app_id,
m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name,
COUNT(*) AS success_orders,
COUNT(DISTINCT DATE(COALESCE(o.pay_at, o.created_at))) AS batch_days,
COALESCE(SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS ready_amount,
COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS pending_amount,
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS pending_notify_orders,
MAX(o.pay_at) AS last_pay_at"
)
->groupBy('o.merchant_id', 'o.merchant_app_id', 'm.merchant_no', 'm.merchant_name', 'ma.app_id', 'ma.app_name')
->orderByRaw('SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END) DESC')
->paginate($pageSize, ['*'], 'page', $page);
$items = [];
foreach ($paginator->items() as $row) {
$item = (array)$row;
$item['batch_status'] = (float)($item['pending_amount'] ?? 0) > 0 ? 'pending' : 'ready';
$item['batch_status_text'] = $item['batch_status'] === 'ready' ? 'ready_to_batch' : 'pending_notify';
$items[] = $item;
}
return $this->success([
'summary' => [
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
'app_count' => (int)($summaryRow->app_count ?? 0),
'success_orders' => (int)($summaryRow->success_orders ?? 0),
'batch_days' => (int)($summaryRow->batch_days ?? 0),
'ready_amount' => (string)($summaryRow->ready_amount ?? '0.00'),
'pending_amount' => (string)($summaryRow->pending_amount ?? '0.00'),
],
'list' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
]);
}
public function split(Request $request)
{
$page = (int)$request->get('page', 1);
$pageSize = (int)$request->get('page_size', 10);
$filters = $this->buildFilters($request);
$baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1);
$summaryRow = (clone $baseQuery)
->selectRaw(
'COUNT(DISTINCT o.channel_id) AS channel_count,
COUNT(DISTINCT o.merchant_id) AS merchant_count,
COUNT(*) AS order_count,
COALESCE(SUM(o.real_amount), 0) AS gross_amount,
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
COALESCE(SUM((o.real_amount - o.fee) * COALESCE(pc.split_ratio, 100) / 100), 0) AS merchant_share_amount,
COALESCE(SUM((o.real_amount - o.fee) * (100 - COALESCE(pc.split_ratio, 100)) / 100), 0) AS platform_share_amount,
COALESCE(SUM(o.real_amount * COALESCE(pc.chan_cost, 0) / 100), 0) AS channel_cost_amount'
)
->first();
$paginator = (clone $baseQuery)
->selectRaw(
'o.merchant_id, o.channel_id, o.method_id,
m.merchant_no, m.merchant_name,
pm.method_code, pm.method_name,
pc.chan_code, pc.chan_name,
COALESCE(pc.split_ratio, 100) AS split_ratio,
COALESCE(pc.chan_cost, 0) AS chan_cost,
COUNT(*) AS order_count,
COALESCE(SUM(o.real_amount), 0) AS gross_amount,
COALESCE(SUM(o.fee), 0) AS fee_amount,
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
COALESCE(SUM((o.real_amount - o.fee) * COALESCE(pc.split_ratio, 100) / 100), 0) AS merchant_share_amount,
COALESCE(SUM((o.real_amount - o.fee) * (100 - COALESCE(pc.split_ratio, 100)) / 100), 0) AS platform_share_amount,
COALESCE(SUM(o.real_amount * COALESCE(pc.chan_cost, 0) / 100), 0) AS channel_cost_amount,
MAX(o.pay_at) AS last_pay_at'
)
->groupBy('o.merchant_id', 'o.channel_id', 'o.method_id', 'm.merchant_no', 'm.merchant_name', 'pm.method_code', 'pm.method_name', 'pc.chan_code', 'pc.chan_name', 'pc.split_ratio', 'pc.chan_cost')
->orderByRaw('SUM((o.real_amount - o.fee) * COALESCE(pc.split_ratio, 100) / 100) DESC')
->paginate($pageSize, ['*'], 'page', $page);
return $this->success([
'summary' => [
'channel_count' => (int)($summaryRow->channel_count ?? 0),
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
'order_count' => (int)($summaryRow->order_count ?? 0),
'gross_amount' => (string)($summaryRow->gross_amount ?? '0.00'),
'net_amount' => (string)($summaryRow->net_amount ?? '0.00'),
'merchant_share_amount' => (string)($summaryRow->merchant_share_amount ?? '0.00'),
'platform_share_amount' => (string)($summaryRow->platform_share_amount ?? '0.00'),
'channel_cost_amount' => (string)($summaryRow->channel_cost_amount ?? '0.00'),
],
'list' => array_map(fn ($row) => (array)$row, $paginator->items()),
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
]);
}
public function invoice(Request $request)
{
$page = (int)$request->get('page', 1);
$pageSize = (int)$request->get('page_size', 10);
$filters = $this->buildFilters($request);
$baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1);
$summaryRow = (clone $baseQuery)
->selectRaw(
'COUNT(DISTINCT o.merchant_id) AS merchant_count,
COUNT(DISTINCT o.merchant_app_id) AS app_count,
COUNT(*) AS success_orders,
COALESCE(SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS invoiceable_amount,
COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS pending_invoice_amount,
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS pending_notify_orders'
)
->first();
$paginator = (clone $baseQuery)
->selectRaw(
'o.merchant_id, o.merchant_app_id,
m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name,
COUNT(*) AS success_orders,
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
COALESCE(SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS invoiceable_amount,
COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS pending_invoice_amount,
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS pending_notify_orders,
MAX(o.pay_at) AS last_pay_at'
)
->groupBy('o.merchant_id', 'o.merchant_app_id', 'm.merchant_no', 'm.merchant_name', 'ma.app_id', 'ma.app_name')
->orderByRaw('SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END) DESC')
->paginate($pageSize, ['*'], 'page', $page);
$items = [];
foreach ($paginator->items() as $row) {
$item = (array)$row;
$item['invoice_status'] = (float)($item['pending_invoice_amount'] ?? 0) > 0 ? 'pending' : 'ready';
$item['invoice_status_text'] = $item['invoice_status'] === 'ready' ? 'ready' : 'pending_review';
$items[] = $item;
}
return $this->success([
'summary' => [
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
'app_count' => (int)($summaryRow->app_count ?? 0),
'success_orders' => (int)($summaryRow->success_orders ?? 0),
'invoiceable_amount' => (string)($summaryRow->invoiceable_amount ?? '0.00'),
'pending_invoice_amount' => (string)($summaryRow->pending_invoice_amount ?? '0.00'),
'pending_notify_orders' => (int)($summaryRow->pending_notify_orders ?? 0),
],
'list' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
]);
}
private function buildFilters(Request $request): array
{
$methodCode = trim((string)$request->get('method_code', ''));
$methodId = 0;
if ($methodCode !== '') {
$method = $this->methodRepository->findAnyByCode($methodCode);
$methodId = $method ? (int)$method->id : 0;
}
return [
'merchant_id' => (int)$request->get('merchant_id', 0),
'merchant_app_id' => (int)$request->get('merchant_app_id', 0),
'method_id' => $methodId,
'channel_id' => (int)$request->get('channel_id', 0),
'status' => (string)$request->get('status', ''),
'notify_stat' => (string)$request->get('notify_stat', ''),
'order_id' => trim((string)$request->get('order_id', '')),
'mch_order_no' => trim((string)$request->get('mch_order_no', '')),
'created_from' => trim((string)$request->get('created_from', '')),
'created_to' => trim((string)$request->get('created_to', '')),
];
}
private function buildOrderQuery(array $filters)
{
$query = Db::table('ma_pay_order as o')
->leftJoin('ma_merchant as m', 'm.id', '=', 'o.merchant_id')
->leftJoin('ma_merchant_app as ma', 'ma.id', '=', 'o.merchant_app_id')
->leftJoin('ma_pay_method as pm', 'pm.id', '=', 'o.method_id')
->leftJoin('ma_pay_channel as pc', 'pc.id', '=', 'o.channel_id');
if (!empty($filters['merchant_id'])) {
$query->where('o.merchant_id', (int)$filters['merchant_id']);
}
if (!empty($filters['merchant_app_id'])) {
$query->where('o.merchant_app_id', (int)$filters['merchant_app_id']);
}
if (!empty($filters['method_id'])) {
$query->where('o.method_id', (int)$filters['method_id']);
}
if (!empty($filters['channel_id'])) {
$query->where('o.channel_id', (int)$filters['channel_id']);
}
if (($filters['status'] ?? '') !== '') {
$query->where('o.status', (int)$filters['status']);
}
if (($filters['notify_stat'] ?? '') !== '') {
$query->where('o.notify_stat', (int)$filters['notify_stat']);
}
if (!empty($filters['order_id'])) {
$query->where('o.order_id', 'like', '%' . $filters['order_id'] . '%');
}
if (!empty($filters['mch_order_no'])) {
$query->where('o.mch_order_no', 'like', '%' . $filters['mch_order_no'] . '%');
}
if (!empty($filters['created_from'])) {
$query->where('o.created_at', '>=', $filters['created_from']);
}
if (!empty($filters['created_to'])) {
$query->where('o.created_at', '<=', $filters['created_to']);
}
return $query;
}
private function reconcileStatus(int $status, int $notifyStat): string
{
if ($status === 1 && $notifyStat === 1) {
return 'matched';
}
if ($status === 1 && $notifyStat === 0) {
return 'notify_pending';
}
if ($status === 0) {
return 'pending';
}
if ($status === 2) {
return 'failed';
}
if ($status === 3) {
return 'closed';
}
return 'unknown';
}
private function reconcileStatusText(string $status): string
{
return match ($status) {
'matched' => 'matched',
'notify_pending' => 'notify_pending',
'pending' => 'pending',
'failed' => 'failed',
'closed' => 'closed',
default => 'unknown',
};
}
}

View File

@@ -5,22 +5,18 @@ namespace app\http\admin\controller;
use app\common\base\BaseController;
use app\repositories\MerchantAppRepository;
use app\repositories\MerchantRepository;
use app\services\SystemConfigService;
use support\Request;
/**
* 商户应用管理
*/
class MerchantAppController extends BaseController
{
public function __construct(
protected MerchantAppRepository $merchantAppRepository,
protected MerchantRepository $merchantRepository,
protected SystemConfigService $systemConfigService,
) {
}
/**
* GET /adminapi/merchant-app/list
*/
public function list(Request $request)
{
$page = (int)$request->get('page', 1);
@@ -35,30 +31,59 @@ class MerchantAppController extends BaseController
];
$paginator = $this->merchantAppRepository->searchPaginate($filters, $page, $pageSize);
return $this->page($paginator);
$packageMap = $this->buildPackageMap();
$items = [];
foreach ($paginator->items() as $row) {
$item = (array)$row;
$config = $this->getConfigObject($this->appConfigKey((int)($item['id'] ?? 0)));
$packageCode = trim((string)($config['package_code'] ?? ''));
$item['package_code'] = $packageCode;
$item['package_name'] = $packageCode !== '' ? ($packageMap[$packageCode] ?? $packageCode) : '';
$items[] = $item;
}
return $this->success([
'list' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
]);
}
/**
* GET /adminapi/merchant-app/detail?id=1
*/
public function detail(Request $request)
{
$id = (int)$request->get('id', 0);
if ($id <= 0) {
return $this->fail('应用ID不能为空', 400);
return $this->fail('app id is required', 400);
}
$row = $this->merchantAppRepository->find($id);
if (!$row) {
return $this->fail('应用不存在', 404);
return $this->fail('app not found', 404);
}
return $this->success($row);
}
/**
* POST /adminapi/merchant-app/save
*/
public function configDetail(Request $request)
{
$id = (int)$request->get('id', 0);
if ($id <= 0) {
return $this->fail('app id is required', 400);
}
$app = $this->merchantAppRepository->find($id);
if (!$app) {
return $this->fail('app not found', 404);
}
$config = array_merge($this->defaultAppConfig(), $this->getConfigObject($this->appConfigKey($id)));
return $this->success([
'app' => $app,
'config' => $config,
]);
}
public function save(Request $request)
{
$data = $request->post();
@@ -71,29 +96,28 @@ class MerchantAppController extends BaseController
$status = (int)($data['status'] ?? 1);
if ($merchantId <= 0 || $appId === '' || $appName === '') {
return $this->fail('商户、应用ID、应用名称不能为空', 400);
return $this->fail('merchant_id, app_id and app_name are required', 400);
}
$merchant = $this->merchantRepository->find($merchantId);
if (!$merchant) {
return $this->fail('商户不存在', 404);
return $this->fail('merchant not found', 404);
}
if (!in_array($apiType, ['openapi', 'epay', 'custom', 'default'], true)) {
return $this->fail('api_type 不合法', 400);
return $this->fail('invalid api_type', 400);
}
if ($id > 0) {
$row = $this->merchantAppRepository->find($id);
if (!$row) {
return $this->fail('应用不存在', 404);
return $this->fail('app not found', 404);
}
// app_id 变更需校验唯一
if ($row->app_id !== $appId) {
$exists = $this->merchantAppRepository->findAnyByAppId($appId);
if ($exists) {
return $this->fail('应用ID已存在', 400);
return $this->fail('app_id already exists', 400);
}
}
@@ -105,7 +129,6 @@ class MerchantAppController extends BaseController
'status' => $status,
];
// 可选:前端传入 app_secret 才更新
if (!empty($data['app_secret'])) {
$update['app_secret'] = (string)$data['app_secret'];
}
@@ -114,7 +137,7 @@ class MerchantAppController extends BaseController
} else {
$exists = $this->merchantAppRepository->findAnyByAppId($appId);
if ($exists) {
return $this->fail('应用ID已存在', 400);
return $this->fail('app_id already exists', 400);
}
$secret = !empty($data['app_secret']) ? (string)$data['app_secret'] : $this->generateSecret();
@@ -128,44 +151,95 @@ class MerchantAppController extends BaseController
]);
}
return $this->success(null, '保存成功');
return $this->success(null, 'saved');
}
/**
* POST /adminapi/merchant-app/reset-secret
*/
public function resetSecret(Request $request)
{
$id = (int)$request->post('id', 0);
if ($id <= 0) {
return $this->fail('应用ID不能为空', 400);
return $this->fail('app id is required', 400);
}
$row = $this->merchantAppRepository->find($id);
if (!$row) {
return $this->fail('应用不存在', 404);
return $this->fail('app not found', 404);
}
$secret = $this->generateSecret();
$this->merchantAppRepository->updateById($id, ['app_secret' => $secret]);
return $this->success(['app_secret' => $secret], '重置成功');
return $this->success(['app_secret' => $secret], 'reset success');
}
/**
* POST /adminapi/merchant-app/toggle
*/
public function toggle(Request $request)
{
$id = (int)$request->post('id', 0);
$status = $request->post('status', null);
if ($id <= 0 || $status === null) {
return $this->fail('参数错误', 400);
return $this->fail('invalid params', 400);
}
$ok = $this->merchantAppRepository->updateById($id, ['status' => (int)$status]);
return $ok ? $this->success(null, '操作成功') : $this->fail('操作失败', 500);
return $ok ? $this->success(null, 'updated') : $this->fail('update failed', 500);
}
public function configSave(Request $request)
{
$id = (int)$request->post('id', 0);
if ($id <= 0) {
return $this->fail('app id is required', 400);
}
$app = $this->merchantAppRepository->find($id);
if (!$app) {
return $this->fail('app not found', 404);
}
$signType = trim((string)$request->post('sign_type', 'md5'));
$callbackMode = trim((string)$request->post('callback_mode', 'server'));
if (!in_array($signType, ['md5', 'sha256', 'hmac-sha256'], true)) {
return $this->fail('invalid sign_type', 400);
}
if (!in_array($callbackMode, ['server', 'server+page', 'manual'], true)) {
return $this->fail('invalid callback_mode', 400);
}
$config = [
'package_code' => trim((string)$request->post('package_code', '')),
'notify_url' => trim((string)$request->post('notify_url', '')),
'return_url' => trim((string)$request->post('return_url', '')),
'callback_mode' => $callbackMode,
'sign_type' => $signType,
'order_expire_minutes' => max(0, (int)$request->post('order_expire_minutes', 30)),
'callback_retry_limit' => max(0, (int)$request->post('callback_retry_limit', 6)),
'ip_whitelist' => trim((string)$request->post('ip_whitelist', '')),
'amount_min' => max(0, (float)$request->post('amount_min', 0)),
'amount_max' => max(0, (float)$request->post('amount_max', 0)),
'daily_limit' => max(0, (float)$request->post('daily_limit', 0)),
'notify_enabled' => (int)$request->post('notify_enabled', 1) === 1 ? 1 : 0,
'remark' => trim((string)$request->post('remark', '')),
'updated_at' => date('Y-m-d H:i:s'),
];
if ($config['package_code'] !== '') {
$packageExists = false;
foreach ($this->getConfigEntries('merchant_packages') as $package) {
if (($package['package_code'] ?? '') === $config['package_code']) {
$packageExists = true;
break;
}
}
if (!$packageExists) {
return $this->fail('package_code not found', 400);
}
}
$stored = array_merge($this->defaultAppConfig(), $this->getConfigObject($this->appConfigKey($id)), $config);
$this->systemConfigService->setValue($this->appConfigKey($id), $stored);
return $this->success(null, 'saved');
}
private function generateSecret(): string
@@ -173,5 +247,69 @@ class MerchantAppController extends BaseController
$raw = random_bytes(24);
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
}
private function appConfigKey(int $appId): string
{
return 'merchant_app_config_' . $appId;
}
private function defaultAppConfig(): array
{
return [
'package_code' => '',
'notify_url' => '',
'return_url' => '',
'callback_mode' => 'server',
'sign_type' => 'md5',
'order_expire_minutes' => 30,
'callback_retry_limit' => 6,
'ip_whitelist' => '',
'amount_min' => 0,
'amount_max' => 0,
'daily_limit' => 0,
'notify_enabled' => 1,
'remark' => '',
'updated_at' => '',
];
}
private function getConfigObject(string $configKey): array
{
$raw = $this->systemConfigService->getValue($configKey, '{}');
if (!is_string($raw) || $raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
private function getConfigEntries(string $configKey): array
{
$raw = $this->systemConfigService->getValue($configKey, '[]');
if (!is_string($raw) || $raw === '') {
return [];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return [];
}
return array_values(array_filter($decoded, 'is_array'));
}
private function buildPackageMap(): array
{
$map = [];
foreach ($this->getConfigEntries('merchant_packages') as $package) {
$packageCode = trim((string)($package['package_code'] ?? ''));
if ($packageCode === '') {
continue;
}
$map[$packageCode] = trim((string)($package['package_name'] ?? $packageCode));
}
return $map;
}
}

View File

@@ -4,21 +4,18 @@ namespace app\http\admin\controller;
use app\common\base\BaseController;
use app\repositories\MerchantRepository;
use app\services\SystemConfigService;
use support\Db;
use support\Request;
/**
* 商户管理
*/
class MerchantController extends BaseController
{
public function __construct(
protected MerchantRepository $merchantRepository,
protected SystemConfigService $systemConfigService,
) {
}
/**
* GET /adminapi/merchant/list
*/
public function list(Request $request)
{
$page = (int)$request->get('page', 1);
@@ -31,30 +28,59 @@ class MerchantController extends BaseController
];
$paginator = $this->merchantRepository->searchPaginate($filters, $page, $pageSize);
return $this->page($paginator);
$groupMap = $this->buildGroupMap();
$items = [];
foreach ($paginator->items() as $row) {
$item = (array)$row;
$profile = $this->getConfigObject($this->merchantProfileKey((int)($item['id'] ?? 0)));
$groupCode = trim((string)($profile['group_code'] ?? ''));
$item['group_code'] = $groupCode;
$item['group_name'] = $groupCode !== '' ? ($groupMap[$groupCode] ?? $groupCode) : '';
$items[] = $item;
}
return $this->success([
'list' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
]);
}
/**
* GET /adminapi/merchant/detail?id=1
*/
public function detail(Request $request)
{
$id = (int)$request->get('id', 0);
if ($id <= 0) {
return $this->fail('商户ID不能为空', 400);
return $this->fail('merchant id is required', 400);
}
$row = $this->merchantRepository->find($id);
if (!$row) {
return $this->fail('商户不存在', 404);
return $this->fail('merchant not found', 404);
}
return $this->success($row);
}
/**
* POST /adminapi/merchant/save
*/
public function profileDetail(Request $request)
{
$id = (int)$request->get('id', 0);
if ($id <= 0) {
return $this->fail('merchant id is required', 400);
}
$merchant = $this->merchantRepository->find($id);
if (!$merchant) {
return $this->fail('merchant not found', 404);
}
$profile = array_merge($this->defaultMerchantProfile(), $this->getConfigObject($this->merchantProfileKey($id)));
return $this->success([
'merchant' => $merchant,
'profile' => $profile,
]);
}
public function save(Request $request)
{
$data = $request->post();
@@ -66,11 +92,11 @@ class MerchantController extends BaseController
$status = (int)($data['status'] ?? 1);
if ($merchantNo === '' || $merchantName === '') {
return $this->fail('商户号与商户名称不能为空', 400);
return $this->fail('merchant_no and merchant_name are required', 400);
}
if (!in_array($fundsMode, ['direct', 'wallet', 'hybrid'], true)) {
return $this->fail('资金模式不合法', 400);
return $this->fail('invalid funds_mode', 400);
}
if ($id > 0) {
@@ -83,7 +109,7 @@ class MerchantController extends BaseController
} else {
$exists = $this->merchantRepository->findByMerchantNo($merchantNo);
if ($exists) {
return $this->fail('商户号已存在', 400);
return $this->fail('merchant_no already exists', 400);
}
$this->merchantRepository->create([
@@ -94,23 +120,757 @@ class MerchantController extends BaseController
]);
}
return $this->success(null, '保存成功');
return $this->success(null, 'saved');
}
/**
* POST /adminapi/merchant/toggle
*/
public function toggle(Request $request)
{
$id = (int)$request->post('id', 0);
$status = $request->post('status', null);
if ($id <= 0 || $status === null) {
return $this->fail('参数错误', 400);
return $this->fail('invalid params', 400);
}
$ok = $this->merchantRepository->updateById($id, ['status' => (int)$status]);
return $ok ? $this->success(null, '操作成功') : $this->fail('操作失败', 500);
return $ok ? $this->success(null, 'updated') : $this->fail('update failed', 500);
}
public function profileSave(Request $request)
{
$merchantId = (int)$request->post('merchant_id', 0);
if ($merchantId <= 0) {
return $this->fail('merchant_id is required', 400);
}
$merchant = $this->merchantRepository->find($merchantId);
if (!$merchant) {
return $this->fail('merchant not found', 404);
}
$riskLevel = trim((string)$request->post('risk_level', 'standard'));
$settlementCycle = trim((string)$request->post('settlement_cycle', 't1'));
if (!in_array($riskLevel, ['low', 'standard', 'high'], true)) {
return $this->fail('invalid risk_level', 400);
}
if (!in_array($settlementCycle, ['d0', 't1', 'manual'], true)) {
return $this->fail('invalid settlement_cycle', 400);
}
$profile = [
'group_code' => trim((string)$request->post('group_code', '')),
'contact_name' => trim((string)$request->post('contact_name', '')),
'contact_phone' => trim((string)$request->post('contact_phone', '')),
'notify_email' => trim((string)$request->post('notify_email', '')),
'callback_domain' => trim((string)$request->post('callback_domain', '')),
'callback_ip_whitelist' => trim((string)$request->post('callback_ip_whitelist', '')),
'risk_level' => $riskLevel,
'single_limit' => max(0, (float)$request->post('single_limit', 0)),
'daily_limit' => max(0, (float)$request->post('daily_limit', 0)),
'settlement_cycle' => $settlementCycle,
'tech_support' => trim((string)$request->post('tech_support', '')),
'remark' => trim((string)$request->post('remark', '')),
'updated_at' => date('Y-m-d H:i:s'),
];
if ($profile['group_code'] !== '') {
$groupExists = false;
foreach ($this->getConfigEntries('merchant_groups') as $group) {
if (($group['group_code'] ?? '') === $profile['group_code']) {
$groupExists = true;
break;
}
}
if (!$groupExists) {
return $this->fail('group_code not found', 400);
}
}
$stored = array_merge($this->defaultMerchantProfile(), $this->getConfigObject($this->merchantProfileKey($merchantId)), $profile);
$this->systemConfigService->setValue($this->merchantProfileKey($merchantId), $stored);
return $this->success(null, 'saved');
}
public function statistics(Request $request)
{
$page = (int)$request->get('page', 1);
$pageSize = (int)$request->get('page_size', 10);
$filters = $this->buildOpFilters($request);
$summaryQuery = Db::table('ma_merchant as m')
->leftJoin('ma_merchant_app as ma', 'ma.merchant_id', '=', 'm.id')
->leftJoin('ma_pay_channel as pc', 'pc.merchant_id', '=', 'm.id')
->leftJoin('ma_pay_order as o', function ($join) use ($filters) {
$join->on('o.merchant_id', '=', 'm.id');
if (!empty($filters['created_from'])) {
$join->where('o.created_at', '>=', $filters['created_from']);
}
if (!empty($filters['created_to'])) {
$join->where('o.created_at', '<=', $filters['created_to']);
}
});
$this->applyMerchantFilters($summaryQuery, $filters);
$summaryRow = $summaryQuery
->selectRaw(
'COUNT(DISTINCT m.id) AS merchant_count,
COUNT(DISTINCT CASE WHEN m.status = 1 THEN m.id END) AS active_merchant_count,
COUNT(DISTINCT ma.id) AS app_count,
COUNT(DISTINCT pc.id) AS channel_count,
COUNT(DISTINCT o.id) AS order_count,
COUNT(DISTINCT CASE WHEN o.status = 1 THEN o.id END) AS success_order_count,
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount ELSE 0 END), 0) AS success_amount,
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.fee ELSE 0 END), 0) AS fee_amount'
)
->first();
$listQuery = Db::table('ma_merchant as m')
->leftJoin('ma_merchant_app as ma', 'ma.merchant_id', '=', 'm.id')
->leftJoin('ma_pay_channel as pc', 'pc.merchant_id', '=', 'm.id')
->leftJoin('ma_pay_order as o', function ($join) use ($filters) {
$join->on('o.merchant_id', '=', 'm.id');
if (!empty($filters['created_from'])) {
$join->where('o.created_at', '>=', $filters['created_from']);
}
if (!empty($filters['created_to'])) {
$join->where('o.created_at', '<=', $filters['created_to']);
}
});
$this->applyMerchantFilters($listQuery, $filters);
$paginator = $listQuery
->selectRaw(
'm.id, m.merchant_no, m.merchant_name, m.funds_mode, m.status, m.created_at,
COUNT(DISTINCT ma.id) AS app_count,
COUNT(DISTINCT CASE WHEN ma.status = 1 THEN ma.id END) AS active_app_count,
COUNT(DISTINCT pc.id) AS channel_count,
COUNT(DISTINCT o.id) AS order_count,
COUNT(DISTINCT CASE WHEN o.status = 1 THEN o.id END) AS success_order_count,
COUNT(DISTINCT CASE WHEN o.status = 0 THEN o.id END) AS pending_order_count,
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount ELSE 0 END), 0) AS success_amount,
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.fee ELSE 0 END), 0) AS fee_amount,
MAX(o.created_at) AS last_order_at'
)
->groupBy('m.id', 'm.merchant_no', 'm.merchant_name', 'm.funds_mode', 'm.status', 'm.created_at')
->orderByDesc('m.id')
->paginate($pageSize, ['*'], 'page', $page);
return $this->success([
'summary' => [
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
'active_merchant_count' => (int)($summaryRow->active_merchant_count ?? 0),
'app_count' => (int)($summaryRow->app_count ?? 0),
'channel_count' => (int)($summaryRow->channel_count ?? 0),
'order_count' => (int)($summaryRow->order_count ?? 0),
'success_order_count' => (int)($summaryRow->success_order_count ?? 0),
'success_amount' => (string)($summaryRow->success_amount ?? '0.00'),
'fee_amount' => (string)($summaryRow->fee_amount ?? '0.00'),
],
'list' => array_map(fn ($row) => (array)$row, $paginator->items()),
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
]);
}
public function funds(Request $request)
{
$page = (int)$request->get('page', 1);
$pageSize = (int)$request->get('page_size', 10);
$filters = $this->buildOpFilters($request);
$summaryQuery = Db::table('ma_merchant as m')
->leftJoin('ma_pay_order as o', function ($join) use ($filters) {
$join->on('o.merchant_id', '=', 'm.id');
if (!empty($filters['created_from'])) {
$join->where('o.created_at', '>=', $filters['created_from']);
}
if (!empty($filters['created_to'])) {
$join->where('o.created_at', '<=', $filters['created_to']);
}
});
$this->applyMerchantFilters($summaryQuery, $filters);
$summaryRow = $summaryQuery
->selectRaw(
'COUNT(DISTINCT m.id) AS merchant_count,
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount ELSE 0 END), 0) AS settled_amount,
COALESCE(SUM(CASE WHEN o.status = 0 THEN o.amount ELSE 0 END), 0) AS pending_amount,
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.fee ELSE 0 END), 0) AS fee_amount,
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS net_amount,
COUNT(DISTINCT CASE WHEN o.notify_stat = 0 THEN o.id END) AS notify_pending_orders'
)
->first();
$listQuery = Db::table('ma_merchant as m')
->leftJoin('ma_pay_order as o', function ($join) use ($filters) {
$join->on('o.merchant_id', '=', 'm.id');
if (!empty($filters['created_from'])) {
$join->where('o.created_at', '>=', $filters['created_from']);
}
if (!empty($filters['created_to'])) {
$join->where('o.created_at', '<=', $filters['created_to']);
}
});
$this->applyMerchantFilters($listQuery, $filters);
$paginator = $listQuery
->selectRaw(
'm.id, m.merchant_no, m.merchant_name, m.funds_mode, m.status, m.created_at,
COUNT(DISTINCT CASE WHEN o.status = 1 THEN o.id END) AS success_order_count,
COUNT(DISTINCT CASE WHEN o.status = 0 THEN o.id END) AS pending_order_count,
COUNT(DISTINCT CASE WHEN o.notify_stat = 0 THEN o.id END) AS notify_pending_orders,
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount ELSE 0 END), 0) AS settled_amount,
COALESCE(SUM(CASE WHEN o.status = 0 THEN o.amount ELSE 0 END), 0) AS pending_amount,
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.fee ELSE 0 END), 0) AS fee_amount,
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS net_amount,
MAX(o.pay_at) AS last_pay_at'
)
->groupBy('m.id', 'm.merchant_no', 'm.merchant_name', 'm.funds_mode', 'm.status', 'm.created_at')
->orderByRaw('COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) DESC')
->paginate($pageSize, ['*'], 'page', $page);
return $this->success([
'summary' => [
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
'settled_amount' => (string)($summaryRow->settled_amount ?? '0.00'),
'pending_amount' => (string)($summaryRow->pending_amount ?? '0.00'),
'fee_amount' => (string)($summaryRow->fee_amount ?? '0.00'),
'net_amount' => (string)($summaryRow->net_amount ?? '0.00'),
'notify_pending_orders' => (int)($summaryRow->notify_pending_orders ?? 0),
],
'list' => array_map(fn ($row) => (array)$row, $paginator->items()),
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
]);
}
public function audit(Request $request)
{
$page = (int)$request->get('page', 1);
$pageSize = (int)$request->get('page_size', 10);
$auditStatus = trim((string)$request->get('audit_status', ''));
$keyword = trim((string)$request->get('keyword', ''));
$summaryQuery = Db::table('ma_merchant as m');
if ($keyword !== '') {
$summaryQuery->where(function ($query) use ($keyword) {
$query->where('m.merchant_no', 'like', '%' . $keyword . '%')
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%');
});
}
if ($auditStatus === 'pending') {
$summaryQuery->where('m.status', 0);
} elseif ($auditStatus === 'approved') {
$summaryQuery->where('m.status', 1);
}
$summaryRow = $summaryQuery
->selectRaw(
'COUNT(DISTINCT m.id) AS merchant_count,
COUNT(DISTINCT CASE WHEN m.status = 0 THEN m.id END) AS pending_count,
COUNT(DISTINCT CASE WHEN m.status = 1 THEN m.id END) AS approved_count'
)
->first();
$listQuery = Db::table('ma_merchant as m')
->leftJoin('ma_merchant_app as ma', 'ma.merchant_id', '=', 'm.id');
if ($keyword !== '') {
$listQuery->where(function ($query) use ($keyword) {
$query->where('m.merchant_no', 'like', '%' . $keyword . '%')
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%');
});
}
if ($auditStatus === 'pending') {
$listQuery->where('m.status', 0);
} elseif ($auditStatus === 'approved') {
$listQuery->where('m.status', 1);
}
$paginator = $listQuery
->selectRaw(
'm.id, m.merchant_no, m.merchant_name, m.funds_mode, m.status, m.created_at, m.updated_at,
COUNT(DISTINCT ma.id) AS app_count,
COUNT(DISTINCT CASE WHEN ma.status = 1 THEN ma.id END) AS active_app_count,
COUNT(DISTINCT CASE WHEN ma.status = 0 THEN ma.id END) AS disabled_app_count'
)
->groupBy('m.id', 'm.merchant_no', 'm.merchant_name', 'm.funds_mode', 'm.status', 'm.created_at', 'm.updated_at')
->orderBy('m.status', 'asc')
->orderByDesc('m.id')
->paginate($pageSize, ['*'], 'page', $page);
$items = [];
foreach ($paginator->items() as $row) {
$item = (array)$row;
$item['audit_status'] = (int)($item['status'] ?? 0) === 1 ? 'approved' : 'pending';
$item['audit_status_text'] = $item['audit_status'] === 'approved' ? 'approved' : 'pending';
$items[] = $item;
}
return $this->success([
'summary' => [
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
'pending_count' => (int)($summaryRow->pending_count ?? 0),
'approved_count' => (int)($summaryRow->approved_count ?? 0),
],
'list' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
]);
}
public function auditAction(Request $request)
{
$id = (int)$request->post('id', 0);
$action = trim((string)$request->post('action', ''));
if ($id <= 0 || !in_array($action, ['approve', 'suspend'], true)) {
return $this->fail('invalid params', 400);
}
$status = $action === 'approve' ? 1 : 0;
Db::connection()->transaction(function () use ($id, $status) {
Db::table('ma_merchant')->where('id', $id)->update([
'status' => $status,
'updated_at' => date('Y-m-d H:i:s'),
]);
Db::table('ma_merchant_app')->where('merchant_id', $id)->update([
'status' => $status,
'updated_at' => date('Y-m-d H:i:s'),
]);
});
return $this->success(null, $action === 'approve' ? 'approved' : 'suspended');
}
public function groupList(Request $request)
{
$page = max(1, (int)$request->get('page', 1));
$pageSize = max(1, (int)$request->get('page_size', 10));
$keyword = trim((string)$request->get('keyword', ''));
$status = $request->get('status', '');
$items = array_map([$this, 'normalizeGroupItem'], $this->getConfigEntries('merchant_groups'));
$items = array_values(array_filter($items, function (array $item) use ($keyword, $status) {
if ($keyword !== '') {
$haystacks = [
strtolower((string)($item['group_code'] ?? '')),
strtolower((string)($item['group_name'] ?? '')),
strtolower((string)($item['remark'] ?? '')),
];
$needle = strtolower($keyword);
$matched = false;
foreach ($haystacks as $haystack) {
if ($haystack !== '' && str_contains($haystack, $needle)) {
$matched = true;
break;
}
}
if (!$matched) {
return false;
}
}
if ($status !== '' && (int)$item['status'] !== (int)$status) {
return false;
}
return true;
}));
usort($items, function (array $a, array $b) {
$sortCompare = (int)($a['sort'] ?? 0) <=> (int)($b['sort'] ?? 0);
if ($sortCompare !== 0) {
return $sortCompare;
}
return strcmp((string)($b['updated_at'] ?? ''), (string)($a['updated_at'] ?? ''));
});
return $this->success($this->buildConfigPagePayload(
$items,
$page,
$pageSize,
[
'group_count' => count($items),
'active_count' => count(array_filter($items, fn (array $item) => (int)$item['status'] === 1)),
'disabled_count' => count(array_filter($items, fn (array $item) => (int)$item['status'] !== 1)),
]
));
}
public function groupSave(Request $request)
{
$data = $request->post();
$id = trim((string)($data['id'] ?? ''));
$groupCode = trim((string)($data['group_code'] ?? ''));
$groupName = trim((string)($data['group_name'] ?? ''));
if ($groupCode === '' || $groupName === '') {
return $this->fail('group_code and group_name are required', 400);
}
$items = array_map([$this, 'normalizeGroupItem'], $this->getConfigEntries('merchant_groups'));
foreach ($items as $item) {
if (($item['group_code'] ?? '') === $groupCode && ($item['id'] ?? '') !== $id) {
return $this->fail('group_code already exists', 400);
}
}
$now = date('Y-m-d H:i:s');
$saved = false;
foreach ($items as &$item) {
if (($item['id'] ?? '') !== $id || $id === '') {
continue;
}
$item = array_merge($item, [
'group_code' => $groupCode,
'group_name' => $groupName,
'sort' => (int)($data['sort'] ?? 0),
'status' => (int)($data['status'] ?? 1),
'remark' => trim((string)($data['remark'] ?? '')),
'updated_at' => $now,
]);
$saved = true;
break;
}
unset($item);
if (!$saved) {
$items[] = [
'id' => $id !== '' ? $id : uniqid('grp_', true),
'group_code' => $groupCode,
'group_name' => $groupName,
'sort' => (int)($data['sort'] ?? 0),
'status' => (int)($data['status'] ?? 1),
'remark' => trim((string)($data['remark'] ?? '')),
'merchant_count' => 0,
'created_at' => $now,
'updated_at' => $now,
];
}
$this->setConfigEntries('merchant_groups', $items);
return $this->success(null, 'saved');
}
public function groupDelete(Request $request)
{
$id = trim((string)$request->post('id', ''));
if ($id === '') {
return $this->fail('id is required', 400);
}
$items = array_map([$this, 'normalizeGroupItem'], $this->getConfigEntries('merchant_groups'));
$filtered = array_values(array_filter($items, fn (array $item) => ($item['id'] ?? '') !== $id));
if (count($filtered) === count($items)) {
return $this->fail('group not found', 404);
}
$this->setConfigEntries('merchant_groups', $filtered);
return $this->success(null, 'deleted');
}
public function packageList(Request $request)
{
$page = max(1, (int)$request->get('page', 1));
$pageSize = max(1, (int)$request->get('page_size', 10));
$keyword = trim((string)$request->get('keyword', ''));
$status = $request->get('status', '');
$apiType = trim((string)$request->get('api_type', ''));
$items = array_map([$this, 'normalizePackageItem'], $this->getConfigEntries('merchant_packages'));
$items = array_values(array_filter($items, function (array $item) use ($keyword, $status, $apiType) {
if ($keyword !== '') {
$haystacks = [
strtolower((string)($item['package_code'] ?? '')),
strtolower((string)($item['package_name'] ?? '')),
strtolower((string)($item['fee_desc'] ?? '')),
strtolower((string)($item['remark'] ?? '')),
];
$needle = strtolower($keyword);
$matched = false;
foreach ($haystacks as $haystack) {
if ($haystack !== '' && str_contains($haystack, $needle)) {
$matched = true;
break;
}
}
if (!$matched) {
return false;
}
}
if ($status !== '' && (int)$item['status'] !== (int)$status) {
return false;
}
if ($apiType !== '' && (string)$item['api_type'] !== $apiType) {
return false;
}
return true;
}));
usort($items, function (array $a, array $b) {
$sortCompare = (int)($a['sort'] ?? 0) <=> (int)($b['sort'] ?? 0);
if ($sortCompare !== 0) {
return $sortCompare;
}
return strcmp((string)($b['updated_at'] ?? ''), (string)($a['updated_at'] ?? ''));
});
$apiTypeCount = [];
foreach ($items as $item) {
$type = (string)($item['api_type'] ?? 'custom');
$apiTypeCount[$type] = ($apiTypeCount[$type] ?? 0) + 1;
}
return $this->success($this->buildConfigPagePayload(
$items,
$page,
$pageSize,
[
'package_count' => count($items),
'active_count' => count(array_filter($items, fn (array $item) => (int)$item['status'] === 1)),
'disabled_count' => count(array_filter($items, fn (array $item) => (int)$item['status'] !== 1)),
'api_type_count' => $apiTypeCount,
]
));
}
public function packageSave(Request $request)
{
$data = $request->post();
$id = trim((string)($data['id'] ?? ''));
$packageCode = trim((string)($data['package_code'] ?? ''));
$packageName = trim((string)($data['package_name'] ?? ''));
$apiType = trim((string)($data['api_type'] ?? 'epay'));
if ($packageCode === '' || $packageName === '') {
return $this->fail('package_code and package_name are required', 400);
}
if (!in_array($apiType, ['epay', 'openapi', 'custom'], true)) {
return $this->fail('invalid api_type', 400);
}
$items = array_map([$this, 'normalizePackageItem'], $this->getConfigEntries('merchant_packages'));
foreach ($items as $item) {
if (($item['package_code'] ?? '') === $packageCode && ($item['id'] ?? '') !== $id) {
return $this->fail('package_code already exists', 400);
}
}
$now = date('Y-m-d H:i:s');
$saved = false;
foreach ($items as &$item) {
if (($item['id'] ?? '') !== $id || $id === '') {
continue;
}
$item = array_merge($item, [
'package_code' => $packageCode,
'package_name' => $packageName,
'api_type' => $apiType,
'sort' => (int)($data['sort'] ?? 0),
'status' => (int)($data['status'] ?? 1),
'channel_limit' => max(0, (int)($data['channel_limit'] ?? 0)),
'daily_limit' => trim((string)($data['daily_limit'] ?? '')),
'fee_desc' => trim((string)($data['fee_desc'] ?? '')),
'callback_policy' => trim((string)($data['callback_policy'] ?? '')),
'remark' => trim((string)($data['remark'] ?? '')),
'updated_at' => $now,
]);
$saved = true;
break;
}
unset($item);
if (!$saved) {
$items[] = [
'id' => $id !== '' ? $id : uniqid('pkg_', true),
'package_code' => $packageCode,
'package_name' => $packageName,
'api_type' => $apiType,
'sort' => (int)($data['sort'] ?? 0),
'status' => (int)($data['status'] ?? 1),
'channel_limit' => max(0, (int)($data['channel_limit'] ?? 0)),
'daily_limit' => trim((string)($data['daily_limit'] ?? '')),
'fee_desc' => trim((string)($data['fee_desc'] ?? '')),
'callback_policy' => trim((string)($data['callback_policy'] ?? '')),
'remark' => trim((string)($data['remark'] ?? '')),
'created_at' => $now,
'updated_at' => $now,
];
}
$this->setConfigEntries('merchant_packages', $items);
return $this->success(null, 'saved');
}
public function packageDelete(Request $request)
{
$id = trim((string)$request->post('id', ''));
if ($id === '') {
return $this->fail('id is required', 400);
}
$items = array_map([$this, 'normalizePackageItem'], $this->getConfigEntries('merchant_packages'));
$filtered = array_values(array_filter($items, fn (array $item) => ($item['id'] ?? '') !== $id));
if (count($filtered) === count($items)) {
return $this->fail('package not found', 404);
}
$this->setConfigEntries('merchant_packages', $filtered);
return $this->success(null, 'deleted');
}
private function buildOpFilters(Request $request): array
{
return [
'merchant_id' => (int)$request->get('merchant_id', 0),
'status' => (string)$request->get('status', ''),
'keyword' => trim((string)$request->get('keyword', '')),
'created_from' => trim((string)$request->get('created_from', '')),
'created_to' => trim((string)$request->get('created_to', '')),
];
}
private function applyMerchantFilters($query, array $filters): void
{
if (($filters['status'] ?? '') !== '') {
$query->where('m.status', (int)$filters['status']);
}
if (!empty($filters['merchant_id'])) {
$query->where('m.id', (int)$filters['merchant_id']);
}
if (!empty($filters['keyword'])) {
$query->where(function ($builder) use ($filters) {
$builder->where('m.merchant_no', 'like', '%' . $filters['keyword'] . '%')
->orWhere('m.merchant_name', 'like', '%' . $filters['keyword'] . '%');
});
}
}
private function getConfigEntries(string $configKey): array
{
$raw = $this->systemConfigService->getValue($configKey, '[]');
if (!is_string($raw) || $raw === '') {
return [];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return [];
}
return array_values(array_filter($decoded, 'is_array'));
}
private function setConfigEntries(string $configKey, array $items): void
{
$this->systemConfigService->setValue($configKey, array_values($items));
}
private function buildConfigPagePayload(array $items, int $page, int $pageSize, array $summary): array
{
$offset = ($page - 1) * $pageSize;
return [
'summary' => $summary,
'list' => array_values(array_slice($items, $offset, $pageSize)),
'total' => count($items),
'page' => $page,
'size' => $pageSize,
];
}
private function normalizeGroupItem(array $item): array
{
return [
'id' => (string)($item['id'] ?? ''),
'group_code' => trim((string)($item['group_code'] ?? '')),
'group_name' => trim((string)($item['group_name'] ?? '')),
'sort' => (int)($item['sort'] ?? 0),
'status' => (int)($item['status'] ?? 1),
'remark' => trim((string)($item['remark'] ?? '')),
'merchant_count' => max(0, (int)($item['merchant_count'] ?? 0)),
'created_at' => (string)($item['created_at'] ?? ''),
'updated_at' => (string)($item['updated_at'] ?? ''),
];
}
private function normalizePackageItem(array $item): array
{
$apiType = trim((string)($item['api_type'] ?? 'epay'));
if (!in_array($apiType, ['epay', 'openapi', 'custom'], true)) {
$apiType = 'custom';
}
return [
'id' => (string)($item['id'] ?? ''),
'package_code' => trim((string)($item['package_code'] ?? '')),
'package_name' => trim((string)($item['package_name'] ?? '')),
'api_type' => $apiType,
'sort' => (int)($item['sort'] ?? 0),
'status' => (int)($item['status'] ?? 1),
'channel_limit' => max(0, (int)($item['channel_limit'] ?? 0)),
'daily_limit' => trim((string)($item['daily_limit'] ?? '')),
'fee_desc' => trim((string)($item['fee_desc'] ?? '')),
'callback_policy' => trim((string)($item['callback_policy'] ?? '')),
'remark' => trim((string)($item['remark'] ?? '')),
'created_at' => (string)($item['created_at'] ?? ''),
'updated_at' => (string)($item['updated_at'] ?? ''),
];
}
private function merchantProfileKey(int $merchantId): string
{
return 'merchant_profile_' . $merchantId;
}
private function defaultMerchantProfile(): array
{
return [
'group_code' => '',
'contact_name' => '',
'contact_phone' => '',
'notify_email' => '',
'callback_domain' => '',
'callback_ip_whitelist' => '',
'risk_level' => 'standard',
'single_limit' => 0,
'daily_limit' => 0,
'settlement_cycle' => 't1',
'tech_support' => '',
'remark' => '',
'updated_at' => '',
];
}
private function buildGroupMap(): array
{
$map = [];
foreach ($this->getConfigEntries('merchant_groups') as $group) {
$groupCode = trim((string)($group['group_code'] ?? ''));
if ($groupCode === '') {
continue;
}
$map[$groupCode] = trim((string)($group['group_name'] ?? $groupCode));
}
return $map;
}
private function getConfigObject(string $configKey): array
{
$raw = $this->systemConfigService->getValue($configKey, '{}');
if (!is_string($raw) || $raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
}

View File

@@ -3,10 +3,15 @@
namespace app\http\admin\controller;
use app\common\base\BaseController;
use app\models\Merchant;
use app\models\MerchantApp;
use app\models\PaymentChannel;
use app\models\PaymentMethod;
use app\repositories\PaymentMethodRepository;
use app\repositories\PaymentOrderRepository;
use app\services\PayOrderService;
use support\Request;
use support\Response;
/**
* 订单管理
@@ -27,28 +32,20 @@ class OrderController extends BaseController
{
$page = (int)$request->get('page', 1);
$pageSize = (int)$request->get('page_size', 10);
$methodCode = trim((string)$request->get('method_code', ''));
$methodId = 0;
if ($methodCode !== '') {
$method = $this->methodRepository->findAnyByCode($methodCode);
$methodId = $method ? (int)$method->id : 0;
}
$filters = [
'merchant_id' => (int)$request->get('merchant_id', 0),
'merchant_app_id' => (int)$request->get('merchant_app_id', 0),
'method_id' => $methodId,
'channel_id' => (int)$request->get('channel_id', 0),
'status' => $request->get('status', ''),
'order_id' => trim((string)$request->get('order_id', '')),
'mch_order_no' => trim((string)$request->get('mch_order_no', '')),
'created_from' => trim((string)$request->get('created_from', '')),
'created_to' => trim((string)$request->get('created_to', '')),
];
$filters = $this->buildListFilters($request);
$paginator = $this->orderRepository->searchPaginate($filters, $page, $pageSize);
return $this->page($paginator);
$items = [];
foreach ($paginator->items() as $row) {
$items[] = $this->formatOrderRow($row);
}
return $this->success([
'list' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
]);
}
/**
@@ -71,7 +68,139 @@ class OrderController extends BaseController
return $this->fail('订单不存在', 404);
}
return $this->success($row);
return $this->success($this->formatOrderRow($row));
}
/**
* GET /adminapi/order/export
*/
public function export(Request $request): Response
{
$limit = 5000;
$filters = $this->buildListFilters($request);
$rows = $this->orderRepository->searchList($filters, $limit);
$merchantIds = [];
$merchantAppIds = [];
$methodIds = [];
$channelIds = [];
$items = [];
foreach ($rows as $row) {
$item = $this->formatOrderRow($row);
$items[] = $item;
if (!empty($item['merchant_id'])) {
$merchantIds[] = (int)$item['merchant_id'];
}
if (!empty($item['merchant_app_id'])) {
$merchantAppIds[] = (int)$item['merchant_app_id'];
}
if (!empty($item['method_id'])) {
$methodIds[] = (int)$item['method_id'];
}
if (!empty($item['channel_id'])) {
$channelIds[] = (int)$item['channel_id'];
}
}
$merchantMap = Merchant::query()
->whereIn('id', array_values(array_unique($merchantIds)))
->get(['id', 'merchant_no', 'merchant_name'])
->keyBy('id');
$merchantAppMap = MerchantApp::query()
->whereIn('id', array_values(array_unique($merchantAppIds)))
->get(['id', 'app_id', 'app_name'])
->keyBy('id');
$methodMap = PaymentMethod::query()
->whereIn('id', array_values(array_unique($methodIds)))
->get(['id', 'method_code', 'method_name'])
->keyBy('id');
$channelMap = PaymentChannel::query()
->whereIn('id', array_values(array_unique($channelIds)))
->get(['id', 'chan_code', 'chan_name'])
->keyBy('id');
$stream = fopen('php://temp', 'r+');
fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, [
'系统单号',
'商户单号',
'商户编号',
'商户名称',
'应用APPID',
'应用名称',
'支付方式编码',
'支付方式名称',
'通道编码',
'通道名称',
'订单金额',
'实收金额',
'手续费',
'币种',
'订单状态',
'路由结果',
'路由模式',
'策略名称',
'通道单号',
'通道交易号',
'通知状态',
'通知次数',
'客户端IP',
'商品标题',
'创建时间',
'支付时间',
'路由错误',
]);
foreach ($items as $item) {
$merchant = $merchantMap->get((int)($item['merchant_id'] ?? 0));
$merchantApp = $merchantAppMap->get((int)($item['merchant_app_id'] ?? 0));
$method = $methodMap->get((int)($item['method_id'] ?? 0));
$channel = $channelMap->get((int)($item['channel_id'] ?? 0));
fputcsv($stream, [
(string)($item['order_id'] ?? ''),
(string)($item['mch_order_no'] ?? ''),
(string)($merchant->merchant_no ?? ''),
(string)($merchant->merchant_name ?? ''),
(string)($merchantApp->app_id ?? ''),
(string)($merchantApp->app_name ?? ''),
(string)($method->method_code ?? ''),
(string)($method->method_name ?? ''),
(string)($channel->chan_code ?? $item['route_channel_code'] ?? ''),
(string)($channel->chan_name ?? $item['route_channel_name'] ?? ''),
(string)($item['amount'] ?? '0.00'),
(string)($item['real_amount'] ?? '0.00'),
(string)($item['fee'] ?? '0.00'),
(string)($item['currency'] ?? ''),
$this->statusText((int)($item['status'] ?? 0)),
(string)($item['route_source_text'] ?? ''),
(string)($item['route_mode_text'] ?? ''),
(string)($item['route_policy_name'] ?? ''),
(string)($item['chan_order_no'] ?? ''),
(string)($item['chan_trade_no'] ?? ''),
$this->notifyStatusText((int)($item['notify_stat'] ?? 0)),
(string)($item['notify_cnt'] ?? '0'),
(string)($item['client_ip'] ?? ''),
(string)($item['subject'] ?? ''),
(string)($item['created_at'] ?? ''),
(string)($item['pay_at'] ?? ''),
(string)($item['route_error']['message'] ?? ''),
]);
}
rewind($stream);
$content = stream_get_contents($stream) ?: '';
fclose($stream);
$filename = 'orders-' . date('Ymd-His') . '.csv';
return response($content, 200, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => "attachment; filename*=UTF-8''" . rawurlencode($filename),
'X-Export-Count' => (string)count($items),
'X-Export-Limit' => (string)$limit,
'X-Export-Limited' => count($items) >= $limit ? '1' : '0',
]);
}
/**
@@ -96,5 +225,92 @@ class OrderController extends BaseController
return $this->fail($e->getMessage(), 400);
}
}
private function formatOrderRow(object $row): array
{
$data = method_exists($row, 'toArray') ? $row->toArray() : (array)$row;
$extra = is_array($data['extra'] ?? null) ? $data['extra'] : [];
$routing = is_array($extra['routing'] ?? null) ? $extra['routing'] : null;
$routeError = is_array($extra['route_error'] ?? null) ? $extra['route_error'] : null;
$data['routing'] = $routing;
$data['route_error'] = $routeError;
$data['route_candidates'] = is_array($routing['candidates'] ?? null) ? $routing['candidates'] : [];
$data['route_policy_name'] = (string)($routing['policy']['policy_name'] ?? '');
$data['route_source'] = (string)($routing['source'] ?? '');
$data['route_source_text'] = $this->routeSourceText($routing, $routeError);
$data['route_mode_text'] = $this->routeModeText((string)($routing['route_mode'] ?? ''));
$data['route_channel_name'] = (string)($routing['selected_channel_name'] ?? '');
$data['route_channel_code'] = (string)($routing['selected_channel_code'] ?? '');
$data['route_state'] = $routeError
? 'error'
: ($routing ? (string)($routing['source'] ?? 'unknown') : 'none');
return $data;
}
private function buildListFilters(Request $request): array
{
$methodCode = trim((string)$request->get('method_code', ''));
$methodId = 0;
if ($methodCode !== '') {
$method = $this->methodRepository->findAnyByCode($methodCode);
$methodId = $method ? (int)$method->id : 0;
}
return [
'merchant_id' => (int)$request->get('merchant_id', 0),
'merchant_app_id' => (int)$request->get('merchant_app_id', 0),
'method_id' => $methodId,
'channel_id' => (int)$request->get('channel_id', 0),
'route_state' => trim((string)$request->get('route_state', '')),
'route_policy_name' => trim((string)$request->get('route_policy_name', '')),
'status' => $request->get('status', ''),
'order_id' => trim((string)$request->get('order_id', '')),
'mch_order_no' => trim((string)$request->get('mch_order_no', '')),
'created_from' => trim((string)$request->get('created_from', '')),
'created_to' => trim((string)$request->get('created_to', '')),
];
}
private function statusText(int $status): string
{
return match ($status) {
0 => '待支付',
1 => '成功',
2 => '失败',
3 => '关闭',
default => (string)$status,
};
}
private function notifyStatusText(int $notifyStatus): string
{
return $notifyStatus === 1 ? '已通知' : '待通知';
}
private function routeSourceText(?array $routing, ?array $routeError): string
{
if ($routeError) {
return '路由失败';
}
return match ((string)($routing['source'] ?? '')) {
'policy' => '策略命中',
'fallback' => '回退选择',
default => '未记录',
};
}
private function routeModeText(string $routeMode): string
{
return match ($routeMode) {
'priority' => '优先级',
'weight' => '权重分流',
'failover' => '主备切换',
'sort' => '排序兜底',
default => $routeMode ?: '-',
};
}
}

View File

@@ -3,76 +3,310 @@
namespace app\http\admin\controller;
use app\common\base\BaseController;
use app\services\SystemConfigService;
use app\services\SystemSettingService;
use support\Db;
use support\Request;
/**
* 系统控制器
*/
class SystemController extends BaseController
{
public function __construct(
protected SystemSettingService $settingService
protected SystemSettingService $settingService,
protected SystemConfigService $configService
) {
}
/**
* GET /system/getDict
* GET /system/getDict/{code}
*
* 获取字典数据
* 支持通过路由参数 code 查询指定字典,不传则返回所有字典
*
* 示例:
* GET /adminapi/system/getDict - 返回所有字典
* GET /adminapi/system/getDict/gender - 返回性别字典
* GET /adminapi/system/getDict/status - 返回状态字典
*/
public function getDict(Request $request, string $code = '')
{
$data = $this->settingService->getDict($code);
return $this->success($data);
}
/**
* GET /system/base-config/tabs
*
* 获取所有Tab配置
* 由 SystemSettingService 负责读取配置和缓存
*/
public function getTabsConfig()
{
$tabs = $this->settingService->getTabs();
return $this->success($tabs);
return $this->success($this->settingService->getTabs());
}
/**
* GET /system/base-config/form/{tabKey}
*
* 获取指定Tab的表单配置
* 从 SystemSettingService 获取合并后的配置
*/
public function getFormConfig(Request $request, string $tabKey)
{
$formConfig = $this->settingService->getFormConfig($tabKey);
return $this->success($formConfig);
return $this->success($this->settingService->getFormConfig($tabKey));
}
/**
* POST /system/base-config/submit/{tabKey}
*
* 提交表单数据
* 接收表单数据直接使用字段名fieldName作为 config_key 保存到数据库
*/
public function submitConfig(Request $request, string $tabKey)
{
$formData = $request->post();
if (empty($formData)) {
return $this->fail('提交数据不能为空', 400);
return $this->fail('submitted data is empty', 400);
}
$this->settingService->saveFormConfig($tabKey, $formData);
return $this->success(null, '保存成功');
return $this->success(null, 'saved');
}
public function logFiles()
{
$logDir = runtime_path('logs');
if (!is_dir($logDir)) {
return $this->success([]);
}
$items = [];
foreach (glob($logDir . DIRECTORY_SEPARATOR . '*.log') ?: [] as $file) {
if (!is_file($file)) {
continue;
}
$items[] = [
'name' => basename($file),
'size' => filesize($file) ?: 0,
'updated_at' => date('Y-m-d H:i:s', filemtime($file) ?: time()),
];
}
usort($items, fn ($a, $b) => strcmp((string)$b['updated_at'], (string)$a['updated_at']));
return $this->success($items);
}
public function logSummary()
{
$logDir = runtime_path('logs');
if (!is_dir($logDir)) {
return $this->success([
'total_files' => 0,
'total_size' => 0,
'latest_file' => '',
'categories' => [],
]);
}
$files = glob($logDir . DIRECTORY_SEPARATOR . '*.log') ?: [];
$categoryStats = [];
$totalSize = 0;
$latestFile = '';
$latestTime = 0;
foreach ($files as $file) {
if (!is_file($file)) {
continue;
}
$size = filesize($file) ?: 0;
$updatedAt = filemtime($file) ?: 0;
$name = basename($file);
$category = $this->resolveLogCategory($name);
$totalSize += $size;
if (!isset($categoryStats[$category])) {
$categoryStats[$category] = [
'category' => $category,
'file_count' => 0,
'total_size' => 0,
];
}
$categoryStats[$category]['file_count']++;
$categoryStats[$category]['total_size'] += $size;
if ($updatedAt >= $latestTime) {
$latestTime = $updatedAt;
$latestFile = $name;
}
}
return $this->success([
'total_files' => count($files),
'total_size' => $totalSize,
'latest_file' => $latestFile,
'categories' => array_values($categoryStats),
]);
}
public function logContent(Request $request)
{
$file = basename(trim((string)$request->get('file', '')));
$lines = max(20, min(1000, (int)$request->get('lines', 200)));
$keyword = trim((string)$request->get('keyword', ''));
$level = strtoupper(trim((string)$request->get('level', '')));
if ($file === '') {
return $this->fail('file is required', 400);
}
$logDir = runtime_path('logs');
$fullPath = realpath($logDir . DIRECTORY_SEPARATOR . $file);
$realLogDir = realpath($logDir);
if (!$fullPath || !$realLogDir || !str_starts_with($fullPath, $realLogDir) || !is_file($fullPath)) {
return $this->fail('log file not found', 404);
}
$contentLines = file($fullPath, FILE_IGNORE_NEW_LINES);
if (!is_array($contentLines)) {
return $this->fail('failed to read log file', 500);
}
if ($keyword !== '') {
$contentLines = array_values(array_filter($contentLines, static function ($line) use ($keyword) {
return stripos($line, $keyword) !== false;
}));
}
if ($level !== '') {
$contentLines = array_values(array_filter($contentLines, static function ($line) use ($level) {
return stripos(strtoupper($line), $level) !== false;
}));
}
$matchedLineCount = count($contentLines);
$tail = array_slice($contentLines, -$lines);
return $this->success([
'file' => $file,
'size' => filesize($fullPath) ?: 0,
'updated_at' => date('Y-m-d H:i:s', filemtime($fullPath) ?: time()),
'line_count' => $matchedLineCount,
'keyword' => $keyword,
'level' => $level,
'lines' => $tail,
'content' => implode(PHP_EOL, $tail),
]);
}
public function noticeOverview()
{
$config = $this->configService->getValues([
'smtp_host',
'smtp_port',
'smtp_ssl',
'smtp_username',
'smtp_password',
'from_email',
'from_name',
]);
$taskSummary = Db::table('ma_notify_task')
->selectRaw(
'COUNT(*) AS total_tasks,
SUM(CASE WHEN status = \'PENDING\' THEN 1 ELSE 0 END) AS pending_tasks,
SUM(CASE WHEN status = \'SUCCESS\' THEN 1 ELSE 0 END) AS success_tasks,
SUM(CASE WHEN status = \'FAIL\' THEN 1 ELSE 0 END) AS fail_tasks,
MAX(last_notify_at) AS last_notify_at'
)
->first();
$orderSummary = Db::table('ma_pay_order')
->selectRaw(
'SUM(CASE WHEN status = 1 AND notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders,
SUM(CASE WHEN status = 1 AND notify_stat = 1 THEN 1 ELSE 0 END) AS notified_orders'
)
->first();
$requiredKeys = ['smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'from_email'];
$configuredCount = 0;
foreach ($requiredKeys as $key) {
if (!empty($config[$key])) {
$configuredCount++;
}
}
return $this->success([
'config' => [
'smtp_host' => (string)($config['smtp_host'] ?? ''),
'smtp_port' => (string)($config['smtp_port'] ?? ''),
'smtp_ssl' => in_array(strtolower((string)($config['smtp_ssl'] ?? '')), ['1', 'true', 'yes', 'on'], true),
'smtp_username' => $this->maskString((string)($config['smtp_username'] ?? '')),
'from_email' => (string)($config['from_email'] ?? ''),
'from_name' => (string)($config['from_name'] ?? ''),
'configured_fields' => $configuredCount,
'required_fields' => count($requiredKeys),
'is_ready' => $configuredCount === count($requiredKeys),
],
'tasks' => [
'total_tasks' => (int)($taskSummary->total_tasks ?? 0),
'pending_tasks' => (int)($taskSummary->pending_tasks ?? 0),
'success_tasks' => (int)($taskSummary->success_tasks ?? 0),
'fail_tasks' => (int)($taskSummary->fail_tasks ?? 0),
'last_notify_at' => (string)($taskSummary->last_notify_at ?? ''),
],
'orders' => [
'notify_pending_orders' => (int)($orderSummary->notify_pending_orders ?? 0),
'notified_orders' => (int)($orderSummary->notified_orders ?? 0),
],
]);
}
public function noticeTest(Request $request)
{
$config = $this->configService->getValues([
'smtp_host',
'smtp_port',
'smtp_ssl',
'smtp_username',
'smtp_password',
'from_email',
]);
$missingFields = [];
foreach (['smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'from_email'] as $field) {
if (empty($config[$field])) {
$missingFields[] = $field;
}
}
if ($missingFields !== []) {
return $this->fail('missing config fields: ' . implode(', ', $missingFields), 400);
}
$host = (string)$config['smtp_host'];
$port = (int)$config['smtp_port'];
$useSsl = in_array(strtolower((string)($config['smtp_ssl'] ?? '')), ['1', 'true', 'yes', 'on'], true);
$transport = ($useSsl ? 'ssl://' : 'tcp://') . $host . ':' . $port;
$errno = 0;
$errstr = '';
$connection = @stream_socket_client($transport, $errno, $errstr, 5, STREAM_CLIENT_CONNECT);
if (!is_resource($connection)) {
return $this->fail('smtp connection failed: ' . ($errstr !== '' ? $errstr : 'unknown error'), 500);
}
stream_set_timeout($connection, 3);
$banner = fgets($connection, 512) ?: '';
fclose($connection);
return $this->success([
'transport' => $transport,
'banner' => trim($banner),
'checked_at' => date('Y-m-d H:i:s'),
'note' => 'only smtp connectivity and basic config were verified; no test email was sent',
], 'smtp connection ok');
}
private function resolveLogCategory(string $fileName): string
{
$name = strtolower($fileName);
if (str_contains($name, 'pay') || str_contains($name, 'notify')) {
return 'payment';
}
if (str_contains($name, 'queue') || str_contains($name, 'job')) {
return 'queue';
}
if (str_contains($name, 'error') || str_contains($name, 'exception')) {
return 'error';
}
if (str_contains($name, 'admin') || str_contains($name, 'system')) {
return 'system';
}
return 'other';
}
private function maskString(string $value): string
{
$length = strlen($value);
if ($length <= 4) {
return $value === '' ? '' : str_repeat('*', $length);
}
return substr($value, 0, 2) . str_repeat('*', max(2, $length - 4)) . substr($value, -2);
}
}

View File

@@ -3,8 +3,7 @@
namespace app\http\api\controller;
use app\common\base\BaseController;
use app\services\api\EpayService;
use app\validation\EpayValidator;
use app\services\api\EpayProtocolService;
use support\Request;
use support\Response;
@@ -14,43 +13,32 @@ use support\Response;
class EpayController extends BaseController
{
public function __construct(
protected EpayService $epayService
) {}
protected EpayProtocolService $epayProtocolService
) {
}
/**
* 页面跳转支付
*/
public function submit(Request $request)
{
$data = match ($request->method()) {
'GET' => $request->get(),
'POST' => $request->post(),
default => $request->all(),
};
try {
// 参数校验(使用自定义 Validator + 场景)
$params = EpayValidator::make($data)
->withScene('submit')
->validate();
$result = $this->epayProtocolService->handleSubmit($request);
$type = $result['response_type'] ?? '';
// 业务处理:创建订单并获取支付参数
$result = $this->epayService->submit($params, $request);
$payParams = $result['pay_params'] ?? [];
// 根据支付参数类型返回响应
if (($payParams['type'] ?? '') === 'redirect' && !empty($payParams['url'])) {
return redirect($payParams['url']);
if ($type === 'redirect' && !empty($result['url'])) {
return redirect($result['url']);
}
if (($payParams['type'] ?? '') === 'form') {
if (!empty($payParams['html'])) {
return response($payParams['html'])->withHeaders(['Content-Type' => 'text/html; charset=UTF-8']);
}
return $this->renderForm($payParams);
if ($type === 'form_html') {
return response((string)($result['html'] ?? ''))
->withHeaders(['Content-Type' => 'text/html; charset=UTF-8']);
}
if ($type === 'form_params') {
return $this->renderForm((array)($result['form'] ?? []));
}
// 如果没有匹配的类型,返回错误
return $this->fail('支付参数生成失败');
} catch (\Throwable $e) {
return $this->fail($e->getMessage());
@@ -62,20 +50,12 @@ class EpayController extends BaseController
*/
public function mapi(Request $request)
{
$data = $request->post();
try {
$params = EpayValidator::make($data)
->withScene('mapi')
->validate();
$result = $this->epayService->mapi($params, $request);
return json($result);
return json($this->epayProtocolService->handleMapi($request));
} catch (\Throwable $e) {
return json([
'code' => 0,
'msg' => $e->getMessage(),
'msg' => $e->getMessage(),
]);
}
}
@@ -85,33 +65,12 @@ class EpayController extends BaseController
*/
public function api(Request $request)
{
$data = array_merge($request->get(), $request->post());
try {
$act = strtolower($data['act'] ?? '');
if ($act === 'order') {
$params = EpayValidator::make($data)
->withScene('api_order')
->validate();
$result = $this->epayService->api($params);
} elseif ($act === 'refund') {
$params = EpayValidator::make($data)
->withScene('api_refund')
->validate();
$result = $this->epayService->api($params);
} else {
$result = [
'code' => 0,
'msg' => '不支持的操作类型',
];
}
return json($result);
return json($this->epayProtocolService->handleApi($request));
} catch (\Throwable $e) {
return json([
'code' => 0,
'msg' => $e->getMessage(),
'msg' => $e->getMessage(),
]);
}
}

View File

@@ -3,8 +3,10 @@
namespace app\http\api\controller;
use app\common\base\BaseController;
use app\services\PayOrderService;
use app\services\PayNotifyService;
use app\services\PluginService;
use support\Request;
use support\Response;
/**
* 支付控制器OpenAPI
@@ -12,30 +14,61 @@ use support\Request;
class PayController extends BaseController
{
public function __construct(
protected PayOrderService $payOrderService
) {}
protected PayNotifyService $payNotifyService,
protected PluginService $pluginService
) {
}
/**
* 创建订单
*/
public function create(Request $request) {}
public function create(Request $request)
{
return $this->fail('not implemented', 501);
}
/**
* 查询订单
*/
public function query(Request $request) {}
public function query(Request $request)
{
return $this->fail('not implemented', 501);
}
/**
* 关闭订单
*/
public function close(Request $request) {}
public function close(Request $request)
{
return $this->fail('not implemented', 501);
}
/**
* 订单退款
*/
public function refund(Request $request) {}
public function refund(Request $request)
{
return $this->fail('not implemented', 501);
}
/**
* 异步通知
*/
public function notify(Request $request) {}
public function notify(Request $request, string $pluginCode)
{
try {
$plugin = $this->pluginService->getPluginInstance($pluginCode);
$result = $this->payNotifyService->handleNotify($pluginCode, $request);
$ackSuccess = method_exists($plugin, 'notifySuccess') ? $plugin->notifySuccess() : 'success';
$ackFail = method_exists($plugin, 'notifyFail') ? $plugin->notifyFail() : 'fail';
if (!($result['ok'] ?? false)) {
return $ackFail instanceof Response ? $ackFail : response((string)$ackFail);
}
return $ackSuccess instanceof Response ? $ackSuccess : response((string)$ackSuccess);
} catch (\Throwable $e) {
return response('fail');
}
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace app\models;
use app\common\base\BaseModel;
/**
* 回调幂等收件箱
*
* 对应表ma_callback_inbox
*/
class CallbackInbox extends BaseModel
{
protected $table = 'ma_callback_inbox';
protected $fillable = [
'event_key',
'plugin_code',
'order_id',
'chan_trade_no',
'payload',
'process_status',
'processed_at',
];
public $timestamps = true;
protected $casts = [
'payload' => 'array',
'process_status' => 'integer',
'processed_at' => 'datetime',
];
}

View File

@@ -0,0 +1,43 @@
<?php
namespace app\repositories;
use app\common\base\BaseRepository;
use app\models\CallbackInbox;
use Illuminate\Database\QueryException;
/**
* 回调幂等收件箱仓储
*/
class CallbackInboxRepository extends BaseRepository
{
public function __construct()
{
parent::__construct(new CallbackInbox());
}
public function findByEventKey(string $eventKey): ?CallbackInbox
{
return $this->model->newQuery()
->where('event_key', $eventKey)
->first();
}
/**
* 尝试创建幂等事件,重复时返回 false。
*/
public function createIfAbsent(array $data): bool
{
try {
$this->model->newQuery()->create($data);
return true;
} catch (QueryException $e) {
// 1062: duplicate entry
if ((int)($e->errorInfo[1] ?? 0) === 1062) {
return false;
}
throw $e;
}
}
}

View File

@@ -5,9 +5,6 @@ namespace app\repositories;
use app\common\base\BaseRepository;
use app\models\PaymentChannel;
/**
* 支付通道仓储
*/
class PaymentChannelRepository extends BaseRepository
{
public function __construct()
@@ -15,9 +12,6 @@ class PaymentChannelRepository extends BaseRepository
parent::__construct(new PaymentChannel());
}
/**
* 根据商户、应用、支付方式查找可用通道
*/
public function findAvailableChannel(int $merchantId, int $merchantAppId, int $methodId): ?PaymentChannel
{
return $this->model->newQuery()
@@ -35,4 +29,49 @@ class PaymentChannelRepository extends BaseRepository
->where('chan_code', $chanCode)
->first();
}
public function searchPaginate(array $filters = [], int $page = 1, int $pageSize = 10)
{
$query = $this->buildSearchQuery($filters);
$query->orderBy('sort', 'asc')->orderByDesc('id');
return $query->paginate($pageSize, ['*'], 'page', $page);
}
public function searchList(array $filters = [])
{
return $this->buildSearchQuery($filters)
->orderBy('sort', 'asc')
->orderByDesc('id')
->get();
}
private function buildSearchQuery(array $filters = [])
{
$query = $this->model->newQuery();
if (!empty($filters['merchant_id'])) {
$query->where('merchant_id', (int)$filters['merchant_id']);
}
if (!empty($filters['merchant_app_id'])) {
$query->where('merchant_app_id', (int)$filters['merchant_app_id']);
}
if (!empty($filters['method_id'])) {
$query->where('method_id', (int)$filters['method_id']);
}
if (($filters['status'] ?? '') !== '' && $filters['status'] !== null) {
$query->where('status', (int)$filters['status']);
}
if (!empty($filters['plugin_code'])) {
$query->where('plugin_code', (string)$filters['plugin_code']);
}
if (!empty($filters['chan_code'])) {
$query->where('chan_code', 'like', '%' . $filters['chan_code'] . '%');
}
if (!empty($filters['chan_name'])) {
$query->where('chan_name', 'like', '%' . $filters['chan_name'] . '%');
}
return $query;
}
}

View File

@@ -10,6 +10,11 @@ use app\models\PaymentOrder;
*/
class PaymentOrderRepository extends BaseRepository
{
public const STATUS_PENDING = 0;
public const STATUS_SUCCESS = 1;
public const STATUS_FAIL = 2;
public const STATUS_CLOSED = 3;
public function __construct()
{
parent::__construct(new PaymentOrder());
@@ -58,6 +63,82 @@ class PaymentOrderRepository extends BaseRepository
* 后台订单列表:支持筛选与模糊搜索
*/
public function searchPaginate(array $filters = [], int $page = 1, int $pageSize = 10)
{
$query = $this->buildSearchQuery($filters);
$query->orderByDesc('id');
return $query->paginate($pageSize, ['*'], 'page', $page);
}
public function searchList(array $filters = [], int $limit = 5000)
{
return $this->buildSearchQuery($filters)
->orderByDesc('id')
->limit($limit)
->get();
}
public function aggregateByChannel(array $channelIds = [], array $filters = []): array
{
if (empty($channelIds)) {
return [];
}
$query = $this->model->newQuery()
->selectRaw(
'channel_id,
COUNT(*) AS total_orders,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_orders,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS pending_orders,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS fail_orders,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS closed_orders,
COALESCE(SUM(amount), 0) AS total_amount,
COALESCE(SUM(CASE WHEN status = ? THEN amount ELSE 0 END), 0) AS success_amount,
SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) AS today_orders,
COALESCE(SUM(CASE WHEN DATE(created_at) = CURDATE() THEN amount ELSE 0 END), 0) AS today_amount,
SUM(CASE WHEN DATE(created_at) = CURDATE() AND status = ? THEN 1 ELSE 0 END) AS today_success_orders,
COALESCE(SUM(CASE WHEN DATE(created_at) = CURDATE() AND status = ? THEN amount ELSE 0 END), 0) AS today_success_amount,
MAX(created_at) AS last_order_at,
MAX(CASE WHEN status = ? THEN pay_at ELSE NULL END) AS last_success_at',
[
self::STATUS_SUCCESS,
self::STATUS_PENDING,
self::STATUS_FAIL,
self::STATUS_CLOSED,
self::STATUS_SUCCESS,
self::STATUS_SUCCESS,
self::STATUS_SUCCESS,
self::STATUS_SUCCESS,
]
)
->whereIn('channel_id', $channelIds);
if (!empty($filters['merchant_id'])) {
$query->where('merchant_id', (int)$filters['merchant_id']);
}
if (!empty($filters['merchant_app_id'])) {
$query->where('merchant_app_id', (int)$filters['merchant_app_id']);
}
if (!empty($filters['method_id'])) {
$query->where('method_id', (int)$filters['method_id']);
}
if (!empty($filters['created_from'])) {
$query->where('created_at', '>=', $filters['created_from']);
}
if (!empty($filters['created_to'])) {
$query->where('created_at', '<=', $filters['created_to']);
}
$rows = $query->groupBy('channel_id')->get();
$result = [];
foreach ($rows as $row) {
$result[(int)$row->channel_id] = $row->toArray();
}
return $result;
}
private function buildSearchQuery(array $filters = [])
{
$query = $this->model->newQuery();
@@ -73,6 +154,28 @@ class PaymentOrderRepository extends BaseRepository
if (!empty($filters['channel_id'])) {
$query->where('channel_id', (int)$filters['channel_id']);
}
if (!empty($filters['route_policy_name'])) {
$query->whereRaw(
"JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.policy.policy_name')) like ?",
['%' . $filters['route_policy_name'] . '%']
);
}
if (($filters['route_state'] ?? '') !== '' && $filters['route_state'] !== null) {
$routeState = (string)$filters['route_state'];
if ($routeState === 'error') {
$query->whereRaw("JSON_EXTRACT(extra, '$.route_error') IS NOT NULL");
} elseif ($routeState === 'none') {
$query->whereRaw("JSON_EXTRACT(extra, '$.route_error') IS NULL");
$query->whereRaw(
"(JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.source')) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.source')) = '')"
);
} else {
$query->whereRaw(
"JSON_EXTRACT(extra, '$.route_error') IS NULL AND JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.source')) = ?",
[$routeState]
);
}
}
if (($filters['status'] ?? '') !== '' && $filters['status'] !== null) {
$query->where('status', (int)$filters['status']);
}
@@ -89,7 +192,6 @@ class PaymentOrderRepository extends BaseRepository
$query->where('created_at', '<=', $filters['created_to']);
}
$query->orderByDesc('id');
return $query->paginate($pageSize, ['*'], 'page', $page);
return $query;
}
}

View File

@@ -1,84 +1,230 @@
<?php
<?php
/**
* 管理后台路由定义
*
* 接口前缀:/adminapi
* 跨域中间件Cors
*/
use Webman\Route;
use app\http\admin\controller\AuthController;
use app\common\middleware\Cors;
use app\http\admin\controller\AdminController;
use app\http\admin\controller\MenuController;
use app\http\admin\controller\SystemController;
use app\http\admin\controller\AuthController;
use app\http\admin\controller\ChannelController;
use app\http\admin\controller\PluginController;
use app\http\admin\controller\MerchantController;
use app\http\admin\controller\FinanceController;
use app\http\admin\controller\MenuController;
use app\http\admin\controller\MerchantAppController;
use app\http\admin\controller\MerchantController;
use app\http\admin\controller\OrderController;
use app\http\admin\controller\PayMethodController;
use app\http\admin\controller\PayPluginController;
use app\http\admin\controller\OrderController;
use app\common\middleware\Cors;
use app\http\admin\controller\PluginController;
use app\http\admin\controller\SystemController;
use app\http\admin\middleware\AuthMiddleware;
use Webman\Route;
Route::group('/adminapi', function () {
// 认证相关无需JWT验证
Route::get('/captcha', [AuthController::class, 'captcha'])->name('captcha')->setParams(['real_name' => '验证码']);
Route::post('/login', [AuthController::class, 'login'])->name('login')->setParams(['real_name' => '登录']);
Route::get('/captcha', [AuthController::class, 'captcha'])
->name('captcha')
->setParams(['real_name' => 'adminCaptcha']);
Route::post('/login', [AuthController::class, 'login'])
->name('login')
->setParams(['real_name' => 'adminLogin']);
// 需要认证的路由组
Route::group('', function () {
// 用户相关需要JWT验证
Route::get('/user/getUserInfo', [AdminController::class, 'getUserInfo'])->name('getUserInfo')->setParams(['real_name' => '获取管理员信息']);
// 菜单相关需要JWT验证
Route::get('/menu/getRouters', [MenuController::class, 'getRouters'])->name('getRouters')->setParams(['real_name' => '获取菜单']);
// 系统相关需要JWT验证
Route::get('/system/getDict[/{code}]', [SystemController::class, 'getDict'])->name('getDict')->setParams(['real_name' => '获取字典']);
// 系统配置相关需要JWT验证
Route::get('/system/base-config/tabs', [SystemController::class, 'getTabsConfig'])->name('getTabsConfig')->setParams(['real_name' => '获取系统配置tabs']);
Route::get('/system/base-config/form/{tabKey}', [SystemController::class, 'getFormConfig'])->name('getFormConfig')->setParams(['real_name' => '获取系统配置form']);
Route::post('/system/base-config/submit/{tabKey}', [SystemController::class, 'submitConfig'])->name('submitConfig')->setParams(['real_name' => '提交系统配置']);
// 通道管理相关需要JWT验证
Route::get('/channel/list', [ChannelController::class, 'list'])->name('list')->setParams(['real_name' => '获取通道列表']);
Route::get('/channel/detail', [ChannelController::class, 'detail'])->name('detail')->setParams(['real_name' => '获取通道详情']);
Route::post('/channel/save', [ChannelController::class, 'save'])->name('save')->setParams(['real_name' => '保存通道']);
// 插件管理相关需要JWT验证
Route::get('/channel/plugins', [PluginController::class, 'plugins'])->name('plugins')->setParams(['real_name' => '获取插件列表']);
Route::get('/channel/plugin/config-schema', [PluginController::class, 'configSchema'])->name('configSchema')->setParams(['real_name' => '获取插件配置schema']);
Route::get('/channel/plugin/products', [PluginController::class, 'products'])->name('products')->setParams(['real_name' => '获取插件产品列表']);
Route::get('/user/getUserInfo', [AdminController::class, 'getUserInfo'])
->name('getUserInfo')
->setParams(['real_name' => 'getUserInfo']);
Route::get('/menu/getRouters', [MenuController::class, 'getRouters'])
->name('getRouters')
->setParams(['real_name' => 'getRouters']);
// 商户管理
Route::get('/merchant/list', [MerchantController::class, 'list'])->name('merchantList')->setParams(['real_name' => '商户列表']);
Route::get('/merchant/detail', [MerchantController::class, 'detail'])->name('merchantDetail')->setParams(['real_name' => '商户详情']);
Route::post('/merchant/save', [MerchantController::class, 'save'])->name('merchantSave')->setParams(['real_name' => '保存商户']);
Route::post('/merchant/toggle', [MerchantController::class, 'toggle'])->name('merchantToggle')->setParams(['real_name' => '启用禁用商户']);
Route::get('/system/getDict[/{code}]', [SystemController::class, 'getDict'])
->name('getDict')
->setParams(['real_name' => 'getSystemDict']);
Route::get('/system/base-config/tabs', [SystemController::class, 'getTabsConfig'])
->name('getTabsConfig')
->setParams(['real_name' => 'getSystemTabs']);
Route::get('/system/base-config/form/{tabKey}', [SystemController::class, 'getFormConfig'])
->name('getFormConfig')
->setParams(['real_name' => 'getSystemForm']);
Route::post('/system/base-config/submit/{tabKey}', [SystemController::class, 'submitConfig'])
->name('submitConfig')
->setParams(['real_name' => 'submitSystemConfig']);
Route::get('/system/log/files', [SystemController::class, 'logFiles'])
->name('systemLogFiles')
->setParams(['real_name' => 'systemLogFiles']);
Route::get('/system/log/summary', [SystemController::class, 'logSummary'])
->name('systemLogSummary')
->setParams(['real_name' => 'systemLogSummary']);
Route::get('/system/log/content', [SystemController::class, 'logContent'])
->name('systemLogContent')
->setParams(['real_name' => 'systemLogContent']);
Route::get('/system/notice/overview', [SystemController::class, 'noticeOverview'])
->name('systemNoticeOverview')
->setParams(['real_name' => 'systemNoticeOverview']);
Route::post('/system/notice/test', [SystemController::class, 'noticeTest'])
->name('systemNoticeTest')
->setParams(['real_name' => 'systemNoticeTest']);
// 商户应用管理
Route::get('/merchant-app/list', [MerchantAppController::class, 'list'])->name('merchantAppList')->setParams(['real_name' => '商户应用列表']);
Route::get('/merchant-app/detail', [MerchantAppController::class, 'detail'])->name('merchantAppDetail')->setParams(['real_name' => '商户应用详情']);
Route::post('/merchant-app/save', [MerchantAppController::class, 'save'])->name('merchantAppSave')->setParams(['real_name' => '保存商户应用']);
Route::post('/merchant-app/reset-secret', [MerchantAppController::class, 'resetSecret'])->name('merchantAppResetSecret')->setParams(['real_name' => '重置应用密钥']);
Route::post('/merchant-app/toggle', [MerchantAppController::class, 'toggle'])->name('merchantAppToggle')->setParams(['real_name' => '启用禁用商户应用']);
Route::get('/finance/reconciliation', [FinanceController::class, 'reconciliation'])
->name('financeReconciliation')
->setParams(['real_name' => 'financeReconciliation']);
Route::get('/finance/settlement', [FinanceController::class, 'settlement'])
->name('financeSettlement')
->setParams(['real_name' => 'financeSettlement']);
Route::get('/finance/batch-settlement', [FinanceController::class, 'batchSettlement'])
->name('financeBatchSettlement')
->setParams(['real_name' => 'financeBatchSettlement']);
Route::get('/finance/settlement-record', [FinanceController::class, 'settlementRecord'])
->name('financeSettlementRecord')
->setParams(['real_name' => 'financeSettlementRecord']);
Route::get('/finance/split', [FinanceController::class, 'split'])
->name('financeSplit')
->setParams(['real_name' => 'financeSplit']);
Route::get('/finance/fee', [FinanceController::class, 'fee'])
->name('financeFee')
->setParams(['real_name' => 'financeFee']);
Route::get('/finance/invoice', [FinanceController::class, 'invoice'])
->name('financeInvoice')
->setParams(['real_name' => 'financeInvoice']);
// 支付方式管理
Route::get('/pay-method/list', [PayMethodController::class, 'list'])->name('payMethodList')->setParams(['real_name' => '支付方式列表']);
Route::post('/pay-method/save', [PayMethodController::class, 'save'])->name('payMethodSave')->setParams(['real_name' => '保存支付方式']);
Route::post('/pay-method/toggle', [PayMethodController::class, 'toggle'])->name('payMethodToggle')->setParams(['real_name' => '启用禁用支付方式']);
Route::get('/channel/list', [ChannelController::class, 'list'])
->name('channelList')
->setParams(['real_name' => 'channelList']);
Route::get('/channel/detail', [ChannelController::class, 'detail'])
->name('channelDetail')
->setParams(['real_name' => 'channelDetail']);
Route::get('/channel/monitor', [ChannelController::class, 'monitor'])
->name('channelMonitor')
->setParams(['real_name' => 'channelMonitor']);
Route::get('/channel/polling', [ChannelController::class, 'polling'])
->name('channelPolling')
->setParams(['real_name' => 'channelPolling']);
Route::get('/channel/policy/list', [ChannelController::class, 'policyList'])
->name('channelPolicyList')
->setParams(['real_name' => 'channelPolicyList']);
Route::post('/channel/save', [ChannelController::class, 'save'])
->name('channelSave')
->setParams(['real_name' => 'channelSave']);
Route::post('/channel/toggle', [ChannelController::class, 'toggle'])
->name('channelToggle')
->setParams(['real_name' => 'channelToggle']);
Route::post('/channel/policy/save', [ChannelController::class, 'policySave'])
->name('channelPolicySave')
->setParams(['real_name' => 'channelPolicySave']);
Route::post('/channel/policy/preview', [ChannelController::class, 'policyPreview'])
->name('channelPolicyPreview')
->setParams(['real_name' => 'channelPolicyPreview']);
Route::post('/channel/policy/delete', [ChannelController::class, 'policyDelete'])
->name('channelPolicyDelete')
->setParams(['real_name' => 'channelPolicyDelete']);
// 插件注册表管理
Route::get('/pay-plugin/list', [PayPluginController::class, 'list'])->name('payPluginList')->setParams(['real_name' => '支付插件注册表列表']);
Route::post('/pay-plugin/save', [PayPluginController::class, 'save'])->name('payPluginSave')->setParams(['real_name' => '保存支付插件注册表']);
Route::post('/pay-plugin/toggle', [PayPluginController::class, 'toggle'])->name('payPluginToggle')->setParams(['real_name' => '启用禁用支付插件']);
Route::get('/channel/plugins', [PluginController::class, 'plugins'])
->name('channelPlugins')
->setParams(['real_name' => 'channelPlugins']);
Route::get('/channel/plugin/config-schema', [PluginController::class, 'configSchema'])
->name('channelPluginConfigSchema')
->setParams(['real_name' => 'channelPluginConfigSchema']);
Route::get('/channel/plugin/products', [PluginController::class, 'products'])
->name('channelPluginProducts')
->setParams(['real_name' => 'channelPluginProducts']);
// 订单管理
Route::get('/order/list', [OrderController::class, 'list'])->name('orderList')->setParams(['real_name' => '订单列表']);
Route::get('/order/detail', [OrderController::class, 'detail'])->name('orderDetail')->setParams(['real_name' => '订单详情']);
Route::post('/order/refund', [OrderController::class, 'refund'])->name('orderRefund')->setParams(['real_name' => '订单退款']);
Route::get('/merchant/list', [MerchantController::class, 'list'])
->name('merchantList')
->setParams(['real_name' => 'merchantList']);
Route::get('/merchant/detail', [MerchantController::class, 'detail'])
->name('merchantDetail')
->setParams(['real_name' => 'merchantDetail']);
Route::get('/merchant/profile/detail', [MerchantController::class, 'profileDetail'])
->name('merchantProfileDetail')
->setParams(['real_name' => 'merchantProfileDetail']);
Route::get('/merchant/statistics', [MerchantController::class, 'statistics'])
->name('merchantStatistics')
->setParams(['real_name' => 'merchantStatistics']);
Route::get('/merchant/funds', [MerchantController::class, 'funds'])
->name('merchantFunds')
->setParams(['real_name' => 'merchantFunds']);
Route::get('/merchant/audit', [MerchantController::class, 'audit'])
->name('merchantAudit')
->setParams(['real_name' => 'merchantAudit']);
Route::get('/merchant/group/list', [MerchantController::class, 'groupList'])
->name('merchantGroupList')
->setParams(['real_name' => 'merchantGroupList']);
Route::post('/merchant/group/save', [MerchantController::class, 'groupSave'])
->name('merchantGroupSave')
->setParams(['real_name' => 'merchantGroupSave']);
Route::post('/merchant/group/delete', [MerchantController::class, 'groupDelete'])
->name('merchantGroupDelete')
->setParams(['real_name' => 'merchantGroupDelete']);
Route::get('/merchant/package/list', [MerchantController::class, 'packageList'])
->name('merchantPackageList')
->setParams(['real_name' => 'merchantPackageList']);
Route::post('/merchant/package/save', [MerchantController::class, 'packageSave'])
->name('merchantPackageSave')
->setParams(['real_name' => 'merchantPackageSave']);
Route::post('/merchant/package/delete', [MerchantController::class, 'packageDelete'])
->name('merchantPackageDelete')
->setParams(['real_name' => 'merchantPackageDelete']);
Route::post('/merchant/save', [MerchantController::class, 'save'])
->name('merchantSave')
->setParams(['real_name' => 'merchantSave']);
Route::post('/merchant/profile/save', [MerchantController::class, 'profileSave'])
->name('merchantProfileSave')
->setParams(['real_name' => 'merchantProfileSave']);
Route::post('/merchant/audit-action', [MerchantController::class, 'auditAction'])
->name('merchantAuditAction')
->setParams(['real_name' => 'merchantAuditAction']);
Route::post('/merchant/toggle', [MerchantController::class, 'toggle'])
->name('merchantToggle')
->setParams(['real_name' => 'merchantToggle']);
Route::get('/merchant-app/list', [MerchantAppController::class, 'list'])
->name('merchantAppList')
->setParams(['real_name' => 'merchantAppList']);
Route::get('/merchant-app/detail', [MerchantAppController::class, 'detail'])
->name('merchantAppDetail')
->setParams(['real_name' => 'merchantAppDetail']);
Route::get('/merchant-app/config/detail', [MerchantAppController::class, 'configDetail'])
->name('merchantAppConfigDetail')
->setParams(['real_name' => 'merchantAppConfigDetail']);
Route::post('/merchant-app/save', [MerchantAppController::class, 'save'])
->name('merchantAppSave')
->setParams(['real_name' => 'merchantAppSave']);
Route::post('/merchant-app/config/save', [MerchantAppController::class, 'configSave'])
->name('merchantAppConfigSave')
->setParams(['real_name' => 'merchantAppConfigSave']);
Route::post('/merchant-app/reset-secret', [MerchantAppController::class, 'resetSecret'])
->name('merchantAppResetSecret')
->setParams(['real_name' => 'merchantAppResetSecret']);
Route::post('/merchant-app/toggle', [MerchantAppController::class, 'toggle'])
->name('merchantAppToggle')
->setParams(['real_name' => 'merchantAppToggle']);
Route::get('/pay-method/list', [PayMethodController::class, 'list'])
->name('payMethodList')
->setParams(['real_name' => 'payMethodList']);
Route::post('/pay-method/save', [PayMethodController::class, 'save'])
->name('payMethodSave')
->setParams(['real_name' => 'payMethodSave']);
Route::post('/pay-method/toggle', [PayMethodController::class, 'toggle'])
->name('payMethodToggle')
->setParams(['real_name' => 'payMethodToggle']);
Route::get('/pay-plugin/list', [PayPluginController::class, 'list'])
->name('payPluginList')
->setParams(['real_name' => 'payPluginList']);
Route::post('/pay-plugin/save', [PayPluginController::class, 'save'])
->name('payPluginSave')
->setParams(['real_name' => 'payPluginSave']);
Route::post('/pay-plugin/toggle', [PayPluginController::class, 'toggle'])
->name('payPluginToggle')
->setParams(['real_name' => 'payPluginToggle']);
Route::get('/order/list', [OrderController::class, 'list'])
->name('orderList')
->setParams(['real_name' => 'orderList']);
Route::get('/order/export', [OrderController::class, 'export'])
->name('orderExport')
->setParams(['real_name' => 'orderExport']);
Route::get('/order/detail', [OrderController::class, 'detail'])
->name('orderDetail')
->setParams(['real_name' => 'orderDetail']);
Route::post('/order/refund', [OrderController::class, 'refund'])
->name('orderRefund')
->setParams(['real_name' => 'orderRefund']);
})->middleware([AuthMiddleware::class]);
})->middleware([Cors::class]);
})->middleware([Cors::class]);

View File

@@ -1,11 +1,12 @@
<?php
/**
* API 路由定义(易支付接口标准
* API 路由定义(易支付兼容 + 通用回调
*/
use Webman\Route;
use app\http\api\controller\EpayController;
use app\http\api\controller\PayController;
Route::group('', function () {
// 页面跳转支付
@@ -16,4 +17,7 @@ Route::group('', function () {
// API接口
Route::get('/api.php', [EpayController::class, 'api']);
// 第三方支付异步回调(按插件区分)
Route::any('/notify/{pluginCode}', [PayController::class, 'notify']);
});

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' => '退款成功',
];
}

View File

@@ -1,7 +1,7 @@
{
{
"formId": "basic-config",
"title": "基础置",
"submitText": "保存置",
"title": "基础置",
"submitText": "保存置",
"submitUrl": "/adminapi/system/base-config/submit/basic",
"cacheKey": "basic_config_cache",
"refreshAfterSubmit": true,
@@ -37,10 +37,10 @@
{
"type": "input",
"field": "site_logo",
"title": "站点Logo",
"title": "站点 Logo",
"value": "",
"props": {
"placeholder": "请输入Logo地址或上传Logo"
"placeholder": "请输入 Logo 地址"
}
},
{
@@ -49,7 +49,7 @@
"title": "备案号",
"value": "",
"props": {
"placeholder": "请输入ICP备案号"
"placeholder": "请输入 ICP 备案号"
}
},
{
@@ -81,4 +81,3 @@
}
]
}

View File

@@ -1,7 +1,7 @@
{
{
"formId": "email-config",
"title": "邮件置",
"submitText": "保存置",
"title": "邮件置",
"submitText": "保存置",
"submitUrl": "/adminapi/system/base-config/submit/email",
"cacheKey": "email_config_cache",
"refreshAfterSubmit": true,
@@ -9,7 +9,7 @@
{
"type": "input",
"field": "smtp_host",
"title": "SMTP服务器",
"title": "SMTP 主机",
"value": "",
"props": {
"placeholder": "例如smtp.qq.com"
@@ -17,14 +17,14 @@
"validate": [
{
"required": true,
"message": "SMTP服务器不能为空"
"message": "SMTP 主机不能为空"
}
]
},
{
"type": "inputNumber",
"field": "smtp_port",
"title": "SMTP端口",
"title": "SMTP 端口",
"value": 465,
"props": {
"min": 1,
@@ -34,14 +34,14 @@
"validate": [
{
"required": true,
"message": "SMTP端口不能为空"
"message": "SMTP 端口不能为空"
}
]
},
{
"type": "switch",
"field": "smtp_ssl",
"title": "启用SSL",
"title": "启用 SSL",
"value": true,
"props": {
"checkedText": "是",
@@ -51,46 +51,46 @@
{
"type": "input",
"field": "smtp_username",
"title": "SMTP用户名",
"title": "SMTP 用户名",
"value": "",
"props": {
"placeholder": "请输入SMTP用户名"
"placeholder": "请输入 SMTP 用户名"
},
"validate": [
{
"required": true,
"message": "SMTP用户名不能为空"
"message": "SMTP 用户名不能为空"
}
]
},
{
"type": "input",
"field": "smtp_password",
"title": "SMTP密码",
"title": "SMTP 密码",
"value": "",
"props": {
"type": "password",
"placeholder": "请输入SMTP密码或授权码"
"placeholder": "请输入 SMTP 密码或授权码"
},
"validate": [
{
"required": true,
"message": "SMTP密码不能为空"
"message": "SMTP 密码不能为空"
}
]
},
{
"type": "input",
"field": "from_email",
"title": "发件邮箱",
"title": "发件邮箱",
"value": "",
"props": {
"placeholder": "请输入发件邮箱地址"
"placeholder": "请输入发件邮箱地址"
},
"validate": [
{
"required": true,
"message": "发件邮箱不能为空"
"message": "发件邮箱不能为空"
},
{
"type": "email",
@@ -101,7 +101,7 @@
{
"type": "input",
"field": "from_name",
"title": "发件名称",
"title": "发件名称",
"value": "",
"props": {
"placeholder": "请输入发件人名称"
@@ -109,4 +109,3 @@
}
]
}

View File

@@ -1,7 +1,7 @@
{
{
"formId": "permission-config",
"title": "权限置",
"submitText": "保存置",
"title": "权限置",
"submitText": "保存置",
"submitUrl": "/adminapi/system/base-config/submit/permission",
"cacheKey": "permission_config_cache",
"refreshAfterSubmit": true,
@@ -96,4 +96,3 @@
}
]
}

View File

@@ -1,27 +1,26 @@
[
[
{
"key": "basic",
"title": "基础置",
"title": "基础置",
"icon": "settings",
"description": "配置系统基础信息包括站点名称、Logo、备案号",
"description": "配置系统基础信息包括站点名称、Logo、备案号和分页默认值。",
"sort": 1,
"disabled": false
},
{
"key": "email",
"title": "邮件置",
"title": "邮件置",
"icon": "email",
"description": "配置邮件服务器相关参数,包括SMTP服务器、端口、账号密码等",
"description": "配置 SMTP 主机、端口、账号和发件人信息,用于通知发送与联通检查。",
"sort": 2,
"disabled": false
},
{
"key": "permission",
"title": "权限置",
"title": "权限置",
"icon": "lock",
"description": "配置系统权限相关参数,预留权限控制功能",
"description": "配置后台权限控制、会话超时、密码强度和登录限制等安全参数。",
"sort": 3,
"disabled": false
}
]

View File

@@ -0,0 +1,20 @@
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `ma_callback_inbox` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`event_key` char(40) NOT NULL DEFAULT '' COMMENT '幂等事件键SHA1',
`plugin_code` varchar(32) NOT NULL DEFAULT '' COMMENT '插件编码',
`order_id` varchar(32) NOT NULL DEFAULT '' COMMENT '系统订单号',
`chan_trade_no` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道交易号',
`payload` json DEFAULT NULL COMMENT '回调原始数据',
`process_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '处理状态0-待处理 1-已处理',
`processed_at` datetime DEFAULT NULL COMMENT '处理完成时间',
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_event_key` (`event_key`),
KEY `idx_order_id` (`order_id`),
KEY `idx_plugin_code` (`plugin_code`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付回调幂等收件箱';

View File

@@ -1,145 +0,0 @@
# 认证策略设计说明
## 设计理念
采用**策略模式**替代中间件方式处理认证,具有以下优势:
1. **灵活扩展**可以轻松添加新的接口标准如易支付、OpenAPI、自定义标准等
2. **按需使用**:控制器可以根据需要选择认证策略,而不是在路由层面强制
3. **易于测试**:策略类可以独立测试,不依赖中间件
4. **代码复用**:不同接口可以共享相同的认证逻辑
## 架构设计
### 1. 核心接口
**`AuthStrategyInterface`** - 认证策略接口
```php
interface AuthStrategyInterface
{
public function authenticate(Request $request): MerchantApp;
}
```
### 2. 策略实现
#### EpayAuthStrategy易支付认证
- 使用 `pid` + `key` + `MD5签名`
- 参数格式:`application/x-www-form-urlencoded`
- 签名算法MD5(排序后的参数字符串 + KEY)
#### OpenApiAuthStrategyOpenAPI认证
- 使用 `app_id` + `timestamp` + `nonce` + `HMAC-SHA256签名`
- 支持请求头或参数传递
- 签名算法HMAC-SHA256(签名字符串, app_secret)
### 3. 认证服务
**`AuthService`** - 认证服务,负责:
- 自动检测接口标准类型
- 根据类型选择对应的认证策略
- 支持手动注册新的认证策略
```php
// 自动检测
$app = $authService->authenticate($request);
// 指定策略类型
$app = $authService->authenticate($request, 'epay');
// 注册新策略
$authService->registerStrategy('custom', CustomAuthStrategy::class);
```
## 使用示例
### 控制器中使用
```php
class PayController extends BaseController
{
public function __construct(
protected PayOrderService $payOrderService,
protected AuthService $authService
) {
}
public function submit(Request $request)
{
// 自动检测或指定策略类型
$app = $this->authService->authenticate($request, 'epay');
// 使用 $app 进行后续业务处理
// ...
}
}
```
### 添加新的认证策略
1. **实现策略接口**
```php
class CustomAuthStrategy implements AuthStrategyInterface
{
public function authenticate(Request $request): MerchantApp
{
// 实现自定义认证逻辑
// ...
}
}
```
2. **注册策略**
```php
// 在服务提供者或启动文件中
$authService = new AuthService();
$authService->registerStrategy('custom', CustomAuthStrategy::class);
```
3. **在控制器中使用**
```php
$app = $this->authService->authenticate($request, 'custom');
```
## 自动检测机制
`AuthService` 会根据请求特征自动检测接口标准:
- **易支付**:检测到 `pid` 参数
- **OpenAPI**:检测到 `X-App-Id` 请求头或 `app_id` 参数
如果无法自动检测,可以手动指定策略类型。
## 优势对比
### 中间件方式(旧方案)
- ❌ 路由配置复杂,每个接口标准需要不同的中间件
- ❌ 难以在同一路由支持多种认证方式
- ❌ 扩展新标准需要修改路由配置
### 策略模式(新方案)
- ✅ 控制器按需选择认证策略
- ✅ 同一路由可以支持多种认证方式(通过参数区分)
- ✅ 扩展新标准只需实现策略接口并注册
- ✅ 代码更清晰,职责分离
## 路由配置
由于不再使用中间件,路由配置更简洁:
```php
// 易支付接口
Route::any('/submit.php', [PayController::class, 'submit']);
Route::post('/mapi.php', [PayController::class, 'mapi']);
Route::get('/api.php', [PayController::class, 'queryOrder']);
// 所有接口都在控制器内部进行认证,无需中间件
```
## 总结
通过策略模式重构认证逻辑,系统具备了:
- **高扩展性**:轻松添加新的接口标准
- **高灵活性**:控制器可以自由选择认证方式
- **高可维护性**:代码结构清晰,易于理解和维护

View File

@@ -1,214 +0,0 @@
# 支付订单表设计说明
## 一、订单表设计原因
### 1.1 订单号设计(双重订单号)
**系统订单号 (`pay_order_id`)**
- **作用**:系统内部唯一标识,用于查询、对账、退款等操作
- **生成规则**`P` + `YYYYMMDDHHmmss` + `6位随机数`P20240101120000123456
- **唯一性**:通过 `uk_pay_order_id` 唯一索引保证
- **优势**
- 全局唯一,不受商户影响
- 便于系统内部查询和关联
- 对账时作为主键
**商户订单号 (`mch_order_no`)**
- **作用**:商户传入的订单号,用于幂等性校验
- **唯一性**:通过 `uk_mch_order_no(merchant_id, mch_order_no)` 联合唯一索引保证
- **优势**
- 同一商户下订单号唯一,防止重复提交
- 商户侧可以自定义订单号规则
- 支持商户订单号查询订单
**为什么需要两个订单号?**
- 系统订单号:保证全局唯一,便于系统内部管理
- 商户订单号:保证商户侧唯一,防止重复支付(幂等性)
### 1.2 关联关系设计
**商户与应用关联 (`merchant_id` + `app_id`)**
- **作用**:标识订单所属商户和应用
- **用途**
- 权限控制(商户只能查询自己的订单)
- 对账统计(按商户/应用维度)
- 通知路由(根据应用配置的通知地址)
**支付通道关联 (`channel_id`)**
- **作用**:记录实际使用的支付通道
- **用途**
- 退款时找到对应的插件和配置
- 对账时关联通道信息
- 统计通道使用情况
**支付方式与产品 (`method_code` + `product_code`)**
- **method_code**支付方式alipay/wechat/unionpay
- 用于统计、筛选、报表
- **product_code**支付产品alipay_h5/alipay_life/wechat_jsapi等
- 由插件根据用户环境自动选择
- 用于记录实际使用的支付产品
### 1.3 金额字段设计
**订单金额 (`amount`)**
- 商户实际收款金额(扣除手续费前)
- 用于退款金额校验、对账
**手续费 (`fee`)**
- 可选字段,记录通道手续费
- 用于对账、结算、利润统计
- 如果不需要详细记录手续费,可以留空或通过 `extra` 存储
**币种 (`currency`)**
- 默认 CNY支持国际化扩展
- 预留字段,便于后续支持多币种
### 1.4 状态流转设计
```
PENDING待支付
├─> SUCCESS支付成功← 收到渠道回调并验签通过
├─> FAIL支付失败← 用户取消、超时、渠道返回失败
└─> CLOSED已关闭← 全额退款后
```
**状态说明**
- **PENDING**:订单创建后,等待用户支付
- **SUCCESS**:支付成功,已收到渠道回调并验签通过
- **FAIL**:支付失败(用户取消、订单超时、渠道返回失败等)
- **CLOSED**:已关闭(全额退款后)
### 1.5 渠道信息设计
**渠道订单号 (`channel_order_no`)**
- 渠道返回的订单号
- 用于查询订单状态、退款等操作
**渠道交易号 (`channel_trade_no`)**
- 部分渠道有交易号概念(如支付宝的 trade_no
- 用于对账、查询等
### 1.6 通知机制设计
**通知状态 (`notify_status`)**
- 0未通知
- 1已通知成功
**通知次数 (`notify_count`)**
- 记录通知次数,用于重试控制
- 配合 `ma_notify_task` 表实现异步通知
### 1.7 扩展性设计
**扩展字段 (`extra`)**
- JSON 格式,存储:
- 支付参数(`pay_params`):前端支付所需的参数
- 退款信息(`refund_info`):退款结果
- 自定义字段:业务扩展字段
**订单过期时间 (`expire_time`)**
- 用于自动关闭超时订单
- 默认 30 分钟,可配置
## 二、索引设计说明
### 2.1 唯一索引
- **`uk_pay_order_id`**:保证系统订单号唯一
- **`uk_mch_order_no(merchant_id, mch_order_no)`**:保证同一商户下商户订单号唯一(幂等性)
### 2.2 普通索引
- **`idx_merchant_app(merchant_id, app_id)`**:商户/应用维度查询
- **`idx_channel_id`**:通道维度查询
- **`idx_method_code`**:支付方式维度统计
- **`idx_status`**:状态筛选
- **`idx_pay_time`**:按支付时间查询(对账、统计)
- **`idx_created_at`**:按创建时间查询(分页、统计)
## 三、可能遗漏的字段(后续扩展)
### 3.1 退款相关字段
如果后续需要支持**部分退款**或**多次退款**,可以考虑添加:
```sql
`refund_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '已退款金额(累计)',
`refund_status` varchar(20) NOT NULL DEFAULT '' COMMENT '退款状态PENDING-退款中, SUCCESS-退款成功, FAIL-退款失败',
`refund_time` datetime DEFAULT NULL COMMENT '最后退款时间',
```
**当前设计**
- 退款信息存储在 `extra['refund_info']`
- 全额退款后订单状态改为 `CLOSED`
- 如果只需要全额退款,当前设计已足够
### 3.2 结算相关字段
如果后续需要**分账/结算**功能,可以考虑添加:
```sql
`settlement_status` varchar(20) NOT NULL DEFAULT '' COMMENT '结算状态PENDING-待结算, SUCCESS-已结算, FAIL-结算失败',
`settlement_time` datetime DEFAULT NULL COMMENT '结算时间',
`settlement_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '结算金额',
```
**当前设计**
- 结算信息可以通过 `extra` 存储
- 如果不需要复杂的结算流程,当前设计已足够
### 3.3 风控相关字段
如果需要**风控功能**,可以考虑添加:
```sql
`risk_level` varchar(20) NOT NULL DEFAULT '' COMMENT '风险等级LOW-低, MEDIUM-中, HIGH-高',
`risk_score` int(11) NOT NULL DEFAULT 0 COMMENT '风险评分',
`risk_reason` varchar(255) NOT NULL DEFAULT '' COMMENT '风险原因',
```
**当前设计**
- 风控信息可以通过 `extra` 存储
- 如果不需要复杂的风控系统,当前设计已足够
### 3.4 其他扩展字段
- **`user_id`**用户ID如果需要关联用户
- **`device_info`**:设备信息(用于风控)
- **`remark`**:备注(管理员备注)
- **`close_reason`**:关闭原因(用户取消/超时/管理员关闭等)
## 四、设计原则总结
1. **幂等性**:通过 `uk_mch_order_no` 保证同一商户下订单号唯一
2. **可追溯性**:记录完整的订单信息、渠道信息、时间信息
3. **可扩展性**:通过 `extra` JSON 字段存储扩展信息
4. **性能优化**:合理的索引设计,支持常见查询场景
5. **业务完整性**:覆盖订单全生命周期(创建→支付→退款→关闭)
## 五、与代码的对应关系
| SQL 字段 | 代码字段 | 说明 |
|---------|---------|------|
| `pay_order_id` | `pay_order_id` | 系统订单号 |
| `merchant_id` | `merchant_id` | 商户ID |
| `app_id` | `app_id` | 应用ID |
| `mch_order_no` | `mch_order_no` | 商户订单号 |
| `method_code` | `method_code` | 支付方式 |
| `product_code` | `product_code` | 支付产品 |
| `channel_id` | `channel_id` | 通道ID |
| `amount` | `amount` | 订单金额 |
| `currency` | `currency` | 币种 |
| `status` | `status` | 订单状态 |
| `channel_order_no` | `channel_order_no` | 渠道订单号 |
| `channel_trade_no` | `channel_trade_no` | 渠道交易号 |
| `extra` | `extra` | 扩展字段JSON |
## 六、注意事项
1. **字段命名统一**SQL 和代码中的字段名必须一致
2. **索引维护**:定期检查索引使用情况,优化慢查询
3. **数据归档**:历史订单数据量大时,考虑归档策略
4. **JSON 字段**`extra` 字段使用 JSON 类型,便于扩展但查询性能略低
5. **时间字段**`pay_time``expire_time` 等时间字段使用 `datetime` 类型,便于查询和统计

View File

@@ -1,485 +0,0 @@
# 支付订单发起流程说明
## 一、业务系统调用统一下单接口
### 1. 接口地址
```
POST /api/pay/unifiedOrder
```
### 2. 请求头(签名认证)
```
X-App-Id: app001 # 应用ID
X-Timestamp: 1704067200 # 时间戳Unix秒
X-Nonce: abc123xyz # 随机字符串
X-Signature: calculated_signature # 签名HMAC-SHA256
Content-Type: application/json
```
### 3. 签名算法
**待签名字符串**
```
app_id={app_id}&timestamp={timestamp}&nonce={nonce}&method=POST&path=/api/pay/unifiedOrder&body_sha256={body_sha256}
```
**计算签名**
```php
$bodySha256 = hash('sha256', json_encode($requestBody));
$signString = "app_id={app_id}&timestamp={timestamp}&nonce={nonce}&method=POST&path=/api/pay/unifiedOrder&body_sha256={bodySha256}";
$signature = hash_hmac('sha256', $signString, $appSecret);
```
### 4. 请求体示例
```json
{
"mch_order_no": "ORDER202401011200001",
"pay_method": "alipay",
"amount": 100.00,
"currency": "CNY",
"subject": "测试商品",
"body": "测试商品描述"
}
```
**字段说明**
- `mch_order_no`:商户订单号(必填,唯一,用于幂等)
- `pay_method`支付方式必填alipay、wechat、unionpay
- `amount`:订单金额(必填,单位:元)
- `currency`币种可选默认CNY
- `subject`:订单标题(必填)
- `body`:订单描述(可选)
### 5. 调用示例cURL
```bash
curl -X POST http://localhost:8787/api/pay/unifiedOrder \
-H "Content-Type: application/json" \
-H "X-App-Id: app001" \
-H "X-Timestamp: 1704067200" \
-H "X-Nonce: abc123xyz" \
-H "X-Signature: calculated_signature" \
-d '{
"mch_order_no": "ORDER202401011200001",
"pay_method": "alipay",
"amount": 100.00,
"subject": "测试商品",
"body": "测试商品描述"
}'
```
### 6. PHP调用示例
```php
<?php
$appId = 'app001';
$appSecret = 'your_app_secret';
$baseUrl = 'http://localhost:8787';
// 准备请求数据
$requestBody = [
'mch_order_no' => 'ORDER202401011200001',
'pay_method' => 'alipay',
'amount' => 100.00,
'subject' => '测试商品',
'body' => '测试商品描述'
];
// 计算签名
$timestamp = time();
$nonce = uniqid();
$bodyJson = json_encode($requestBody);
$bodySha256 = hash('sha256', $bodyJson);
$signString = "app_id={$appId}&timestamp={$timestamp}&nonce={$nonce}&method=POST&path=/api/pay/unifiedOrder&body_sha256={$bodySha256}";
$signature = hash_hmac('sha256', $signString, $appSecret);
// 发送请求
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $baseUrl . '/api/pay/unifiedOrder');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
"X-App-Id: {$appId}",
"X-Timestamp: {$timestamp}",
"X-Nonce: {$nonce}",
"X-Signature: {$signature}",
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$result = json_decode($response, true);
if ($httpCode === 200 && $result['code'] === 200) {
echo "支付订单号:" . $result['data']['pay_order_id'] . "\n";
echo "支付参数:" . json_encode($result['data']['pay_params'], JSON_UNESCAPED_UNICODE) . "\n";
} else {
echo "错误:" . $result['msg'] . "\n";
}
```
## 二、服务端处理流程
### 流程图
```mermaid
sequenceDiagram
participant BizSystem as 业务系统
participant OpenAPI as OpenAPI接口
participant AuthMW as 签名中间件
participant PayService as 订单服务
participant ChannelRouter as 通道路由
participant PluginFactory as 插件工厂
participant Plugin as 支付插件
participant Channel as 第三方渠道
BizSystem->>OpenAPI: POST /api/pay/unifiedOrder
OpenAPI->>AuthMW: 验证签名
AuthMW->>PayService: 调用统一下单
PayService->>PayService: 1. 验证商户应用
PayService->>PayService: 2. 幂等校验
PayService->>PayService: 3. 创建支付订单
PayService->>ChannelRouter: 4. 选择通道
ChannelRouter-->>PayService: 返回通道信息
PayService->>PluginFactory: 5. 实例化插件
PluginFactory-->>PayService: 返回插件实例
PayService->>Plugin: 6. 初始化插件(init)
PayService->>Plugin: 7. 环境检测
PayService->>Plugin: 8. 调用统一下单(unifiedOrder)
Plugin->>Plugin: 8.1 根据环境选择产品
Plugin->>Channel: 8.2 调用第三方接口
Channel-->>Plugin: 返回支付参数
Plugin-->>PayService: 返回支付结果
PayService->>PayService: 9. 更新订单信息
PayService-->>OpenAPI: 返回结果
OpenAPI-->>BizSystem: 返回支付参数
```
### 详细步骤说明
#### 步骤1签名验证中间件
- `OpenApiAuthMiddleware` 验证请求头中的签名
- 验证时间戳5分钟内有效
- 验证签名是否正确
- 将应用信息注入到请求对象
#### 步骤2验证商户应用
- 根据 `app_id` 查询 `ma_merchant_app`
- 检查应用状态是否启用
#### 步骤3幂等校验
- 根据 `merchant_id + mch_order_no` 查询是否已存在订单
- 如果存在,直接返回已有订单信息(支持幂等)
#### 步骤4创建支付订单
- 生成支付订单号(格式:`P20240101120000123456`
- 创建 `ma_pay_order` 记录
- 状态:`PENDING`(待支付)
- 过期时间30分钟后
#### 步骤5通道路由选择
- 根据 `merchant_id + app_id + method_code` 查找可用通道
-`ma_pay_channel` 表中查询
- 选择第一个可用的通道(后续可扩展权重、容灾策略)
#### 步骤6实例化插件
-`PayService` 中根据 `ma_pay_plugin` 注册表解析插件:优先使用表中的 `class_name`,否则按约定使用 `app\common\payment\{Code}Payment` 实例化插件
- 例如:`plugin_code = 'lakala'` → 实例化 `LakalaPlugin`
#### 步骤7初始化插件
- 调用 `$plugin->init($methodCode, $channelConfig)`
- 插件内部切换到指定支付方式的配置和逻辑
- 例如:拉卡拉插件初始化到 `alipay` 模式
#### 步骤8环境检测
- 从请求头 `User-Agent` 判断用户环境
- 环境类型:
- `PC`PC桌面浏览器
- `H5`H5手机浏览器
- `WECHAT`:微信内浏览器
- `ALIPAY_CLIENT`:支付宝客户端
#### 步骤9调用插件统一下单
- 调用 `$plugin->unifiedOrder($orderData, $channelConfig, $env)`
- 插件内部处理:
1. **产品选择**:从通道的 `enabled_products` 中,根据环境自动选择一个产品
- 例如H5环境 → 选择 `alipay_h5`
- 例如:支付宝客户端 → 选择 `alipay_life`
2. **调用第三方接口**:根据产品和支付方式,调用对应的第三方支付接口
- 例如拉卡拉插件的支付宝H5接口
3. **返回支付参数**:返回给业务系统的支付参数
#### 步骤10更新订单
- 更新订单的 `product_code`(实际使用的产品)
- 更新订单的 `channel_id`
- 更新订单的 `channel_order_no`(渠道订单号)
- 保存 `pay_params``extra` 字段
## 三、响应数据格式
### 成功响应
```json
{
"code": 200,
"msg": "success",
"data": {
"pay_order_id": "P20240101120000123456",
"status": "PENDING",
"pay_params": {
"type": "redirect",
"url": "https://mapi.alipay.com/gateway.do?..."
}
}
}
```
### 支付参数类型
根据不同的支付产品和环境,`pay_params` 的格式不同:
#### 1. 跳转支付H5/PC扫码
```json
{
"type": "redirect",
"url": "https://mapi.alipay.com/gateway.do?xxx"
}
```
业务系统需要:**跳转到该URL**
#### 2. 表单提交H5
```json
{
"type": "form",
"method": "POST",
"action": "https://mapi.alipay.com/gateway.do",
"fields": {
"app_id": "xxx",
"method": "alipay.trade.wap.pay",
"biz_content": "{...}"
}
}
```
业务系统需要:**自动提交表单**
#### 3. JSAPI支付微信内/支付宝生活号)
```json
{
"type": "jsapi",
"appId": "wx1234567890",
"timeStamp": "1704067200",
"nonceStr": "abc123",
"package": "prepay_id=wx1234567890",
"signType": "MD5",
"paySign": "calculated_signature"
}
```
业务系统需要:**调用微信/支付宝JSAPI**
#### 4. 二维码支付PC扫码
```json
{
"type": "qrcode",
"qrcode_url": "https://qr.alipay.com/xxx",
"qrcode_data": "data:image/png;base64,..."
}
```
业务系统需要:**展示二维码**
## 四、用户支付流程
### 1. 业务系统处理支付参数
根据 `pay_params.type` 进行不同处理:
```javascript
// 前端处理示例
const payParams = response.data.pay_params;
switch (payParams.type) {
case 'redirect':
// 跳转支付
window.location.href = payParams.url;
break;
case 'form':
// 表单提交
const form = document.createElement('form');
form.method = payParams.method;
form.action = payParams.action;
Object.keys(payParams.fields).forEach(key => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = payParams.fields[key];
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
break;
case 'jsapi':
// 微信JSAPI支付
WeixinJSBridge.invoke('getBrandWCPayRequest', {
appId: payParams.appId,
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: payParams.signType,
paySign: payParams.paySign
}, function(res) {
if (res.err_msg === "get_brand_wcpay_request:ok") {
// 支付成功
}
});
break;
case 'qrcode':
// 展示二维码
document.getElementById('qrcode').src = payParams.qrcode_data;
break;
}
```
### 2. 用户完成支付
- 用户在第三方支付平台完成支付
- 第三方平台异步回调到支付中心
### 3. 支付中心处理回调
- 接收回调:`POST /api/notify/alipay``/api/notify/wechat`
- 验签:使用插件验证回调签名
- 更新订单状态:`PENDING``SUCCESS``FAIL`
- 创建通知任务:异步通知业务系统
### 4. 业务系统接收通知
- 支付中心异步通知业务系统的 `notify_url`
- 业务系统验证签名并处理订单
## 五、查询订单接口
### 接口地址
```
GET /api/pay/query?pay_order_id=P20240101120000123456
```
### 请求头(需要签名)
```
X-App-Id: app001
X-Timestamp: 1704067200
X-Nonce: abc123xyz
X-Signature: calculated_signature
```
### 响应示例
```json
{
"code": 200,
"msg": "success",
"data": {
"pay_order_id": "P20240101120000123456",
"mch_order_no": "ORDER202401011200001",
"status": "SUCCESS",
"amount": 100.00,
"pay_time": "2024-01-01 12:00:30"
}
}
```
## 六、完整调用示例Node.js
```javascript
const crypto = require('crypto');
const axios = require('axios');
class PaymentClient {
constructor(appId, appSecret, baseUrl) {
this.appId = appId;
this.appSecret = appSecret;
this.baseUrl = baseUrl;
}
// 计算签名
calculateSignature(method, path, body, timestamp, nonce) {
const bodySha256 = crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
const signString = `app_id=${this.appId}&timestamp=${timestamp}&nonce=${nonce}&method=${method}&path=${path}&body_sha256=${bodySha256}`;
return crypto.createHmac('sha256', this.appSecret).update(signString).digest('hex');
}
// 统一下单
async unifiedOrder(orderData) {
const timestamp = Math.floor(Date.now() / 1000);
const nonce = Math.random().toString(36).substring(7);
const method = 'POST';
const path = '/api/pay/unifiedOrder';
const signature = this.calculateSignature(method, path, orderData, timestamp, nonce);
const response = await axios.post(`${this.baseUrl}${path}`, orderData, {
headers: {
'Content-Type': 'application/json',
'X-App-Id': this.appId,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Signature': signature
}
});
return response.data;
}
// 查询订单
async queryOrder(payOrderId) {
const timestamp = Math.floor(Date.now() / 1000);
const nonce = Math.random().toString(36).substring(7);
const method = 'GET';
const path = `/api/pay/query?pay_order_id=${payOrderId}`;
const signature = this.calculateSignature(method, path, {}, timestamp, nonce);
const response = await axios.get(`${this.baseUrl}${path}`, {
headers: {
'X-App-Id': this.appId,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Signature': signature
}
});
return response.data;
}
}
// 使用示例
const client = new PaymentClient('app001', 'your_app_secret', 'http://localhost:8787');
// 统一下单
client.unifiedOrder({
mch_order_no: 'ORDER202401011200001',
pay_method: 'alipay',
amount: 100.00,
subject: '测试商品',
body: '测试商品描述'
}).then(result => {
console.log('支付参数:', result.data.pay_params);
// 根据 pay_params.type 处理支付
}).catch(err => {
console.error('下单失败:', err.message);
});
```
## 七、注意事项
1. **幂等性**:相同的 `mch_order_no` 多次调用,返回同一订单信息
2. **签名有效期**时间戳5分钟内有效
3. **订单过期**订单默认30分钟过期
4. **环境检测**系统自动根据UA判断环境选择合适的产品
5. **异步通知**:支付成功后,系统会异步通知业务系统的 `notify_url`
6. **订单查询**:业务系统可通过查询接口主动查询订单状态

View File

@@ -1,182 +0,0 @@
# 支付系统核心实现说明
## 概述
已实现支付系统核心功能,包括:
- 插件化支付通道系统(支持一个插件多个支付方式)
- OpenAPI统一支付网关
- 通道管理与配置
- 订单管理与状态机
- 异步通知机制
## 数据库初始化
执行以下SQL脚本创建表结构
```bash
mysql -u用户名 -p 数据库名 < database/mvp_payment_tables.sql
```
## 核心架构
### 1. 插件系统
- **插件接口**`app/common/contracts/PayPluginInterface.php`
- **抽象基类**`app/common/contracts/AbstractPayPlugin.php`(提供环境检测、产品选择等通用功能)
- **插件类示例**`app/common/payment/LakalaPayment.php`(命名规范:`XxxPayment`
- **插件解析**:由 `PayService``PayOrderService``PluginService` 直接根据 `ma_pay_plugin` 注册表中配置的 `plugin_code` / `class_name` 解析并实例化插件(默认约定类名为 `app\common\payment\{Code}Payment`
**插件特点**
- 一个插件可以支持多个支付方式(如拉卡拉插件支持 alipay/wechat/unionpay
- **支付产品由插件内部定义**,不需要数据库字典表
- 插件根据用户环境PC/H5/微信内/支付宝客户端)自动选择已开通的产品
- 通道配置中,用户只需勾选确认开启了哪些产品(产品编码由插件定义)
- 有些支付平台不区分产品,插件会根据通道配置自行处理
- 通道配置表单由插件动态生成
### 2. 数据模型
- `Merchant`:商户
- `MerchantApp`商户应用AppId/AppSecret
- `PayMethod`支付方式alipay/wechat等
- `PayChannel`:支付通道(绑定到"插件+支付方式",配置已开通的产品列表)
- `PayOrder`:支付订单
- `NotifyTask`:商户通知任务
**注意**:支付产品不由数据库管理,而是由插件通过 `getSupportedProducts()` 方法定义。通道配置中的 `enabled_products` 字段存储的是用户勾选的产品编码数组。
### 3. 服务层
- `PayOrderService`:订单业务编排(统一下单、查询)
- `ChannelRouterService`:通道路由选择
- `NotifyService`:商户通知服务
### 4. API接口
#### OpenAPI对外支付网关
- `POST /api/pay/unifiedOrder`:统一下单(需要签名认证)
- `GET /api/pay/query`:查询订单(需要签名认证)
- `POST /api/notify/alipay`:支付宝回调
- `POST /api/notify/wechat`:微信回调
#### 管理后台API
- `GET /adminapi/channel/plugins`:获取所有可用插件
- `GET /adminapi/channel/plugin/config-schema`获取插件配置表单Schema
- `GET /adminapi/channel/plugin/products`:获取插件支持的支付产品
- `GET /adminapi/channel/list`:通道列表
- `GET /adminapi/channel/detail`:通道详情
- `POST /adminapi/channel/save`:保存通道
## 使用流程
### 1. 创建商户和应用
```sql
INSERT INTO ma_merchant (merchant_no, merchant_name, funds_mode, status)
VALUES ('M001', '测试商户', 'direct', 1);
INSERT INTO ma_merchant_app (merchant_id, app_id, app_secret, app_name, notify_url, status)
VALUES (1, 'app001', 'secret_key_here', '测试应用', 'https://example.com/notify', 1);
```
### 2. 配置支付通道
**配置流程**
1. 创建通道:选择支付方式、支付插件,配置通道基本信息(显示名称、分成比例、通道成本、通道模式、限额等)
2. 配置插件参数:通道创建后,再配置该通道的插件参数信息(通过插件的配置表单动态生成)
通过管理后台或直接操作数据库:
```sql
INSERT INTO ma_pay_channel (
merchant_id, app_id, channel_code, channel_name,
plugin_code, method_code, enabled_products, config_json,
split_ratio, channel_cost, channel_mode,
daily_limit, daily_count, min_amount, max_amount,
status
) VALUES (
1, 1, 'CH001', '拉卡拉-支付宝通道',
'lakala', 'alipay',
'["alipay_h5", "alipay_life"]',
'{"merchant_id": "lakala_merchant", "secret_key": "xxx", "api_url": "https://api.lakala.com"}',
100.00, 0.00, 'wallet',
0.00, 0, NULL, NULL,
1
);
```
**通道字段说明**
- `split_ratio`: 分成比例(%默认100.00
- `channel_cost`: 通道成本(%默认0.00
- `channel_mode`: 通道模式,`wallet`-支付金额扣除手续费后加入商户余额,`direct`-直连到商户
- `daily_limit`: 单日限额0表示不限制
- `daily_count`: 单日限笔0表示不限制
- `min_amount`: 单笔最小金额NULL表示不限制
- `max_amount`: 单笔最大金额NULL表示不限制
### 3. 调用统一下单接口
```bash
curl -X POST http://localhost:8787/api/pay/unifiedOrder \
-H "X-App-Id: app001" \
-H "X-Timestamp: 1234567890" \
-H "X-Nonce: abc123" \
-H "X-Signature: calculated_signature" \
-d '{
"mch_order_no": "ORDER001",
"pay_method": "alipay",
"amount": 100.00,
"subject": "测试订单",
"body": "测试订单描述"
}'
```
### 4. 签名算法
```
signString = "app_id={app_id}&timestamp={timestamp}&nonce={nonce}&method={method}&path={path}&body_sha256={body_sha256}"
signature = HMAC-SHA256(signString, app_secret)
```
## 扩展新插件
1. 创建插件类,继承 `AbstractPayPlugin`,并按照 `XxxPayment` 命名放在 `app/common/payment` 目录:
```php
namespace app\common\payment;
use app\common\contracts\AbstractPayPlugin;
class AlipayPayment extends AbstractPayPlugin
{
public static function getCode(): string { return 'alipay'; }
public static function getName(): string { return '支付宝直连'; }
public static function getSupportedMethods(): array { return ['alipay']; }
// ... 实现其他方法
}
```
2.`ma_pay_plugin` 表中注册插件信息(也可通过后台管理界面维护):
```sql
INSERT INTO ma_pay_plugin (plugin_code, plugin_name, class_name, status)
VALUES ('alipay', '支付宝直连', 'app\\common\\payment\\AlipayPayment', 1);
```
## 注意事项
1. **支付产品定义**:支付产品由插件内部通过 `getSupportedProducts()` 方法定义,不需要数据库字典表。通道配置时,用户只需勾选已开通的产品编码。
2. **环境检测**:插件基类提供 `detectEnvironment()` 方法可根据UA判断环境
3. **产品选择**:插件根据环境从通道已开通产品中自动选择。如果通道配置为空或不区分产品,插件会根据配置自行处理。
4. **通知重试**:使用 `NotifyMerchantJob` 异步重试通知,支持指数退避
5. **幂等性**:统一下单接口支持幂等,相同 `mch_order_no` 返回已有订单
## 后续扩展
- 账务系统(账户、分录、余额)
- 结算系统(可结算金额、结算批次、打款)
- 对账系统(渠道账单导入、差异处理)
- 风控系统(规则引擎、风险预警)

414
doc/project_overview.md Normal file
View File

@@ -0,0 +1,414 @@
# MPay V2 Project Overview
更新日期2026-03-13
## 1. 项目定位
这是一个基于 Webman 的多商户支付中台项目,当前主要目标是:
- 提供后台管理能力,维护商户、应用、支付方式、支付插件、支付通道、订单与系统配置
- 为商户应用提供统一支付能力
- 当前已优先兼容 `epay` 协议,后续可继续扩展更多外部支付协议
- 通过“支付插件 + 通道配置”的方式对接第三方渠道
结合当前代码与数据库,项目已经具备“多商户 -> 多应用 -> 多通道 -> 多插件”的基础骨架。
## 2. 技术栈与运行环境
### 后端技术栈
- PHP `>= 8.1`
- Webman `^2.1`
- webman/database
- webman/redis
- webman/cache
- webman/console
- webman/captcha
- webman/event
- webman/redis-queue
- firebase/php-jwt
- yansongda/pay `~3.7.0`
### 当前环境配置要点
- HTTP 服务监听:`0.0.0.0:8787`
- 数据库MySQL
- 缓存与队列Redis
- 管理后台认证JWT
- 当前 `.env` 已配置远程 MySQL / Redis 地址,开发前需要确认本机网络可达
## 3. 当前环境可调用的 MCP 能力
本次会话中,已确认可以直接用于本项目的 MCP / 环境能力如下:
### MySQL MCP
- 可直接执行 SQL
- 可读取当前开发库表结构与数据
- 已确认能访问 `mpay_admin` 相关表,例如:
- `ma_merchant`
- `ma_merchant_app`
- `ma_pay_channel`
- `ma_pay_order`
- `ma_notify_task`
- `ma_callback_inbox`
- `ma_pay_callback_log`
适合后续继续做:
- 表结构核对
- 初始化数据检查
- 回调与订单状态排查
- 开发联调时快速确认通道配置
### Playwright MCP
- 可进行浏览器打开、点击、表单填写、快照、截图、网络请求分析
- 适合后续验证:
- 管理后台登录流程
- 通道配置页面交互
- 提交支付后的跳转页/表单页
- 回调相关前端可视流程
### MCP 资源浏览
- 可列出 MCP 资源
- 可读取资源内容
- 当前未返回资源模板
### 非 MCP 但对开发有用的本地能力
- Shell 命令执行
- 工作区文件读写
- 代码补丁编辑
## 4. 业务模型总览
### 4.1 商户模型
- 表:`ma_merchant`
- 作用:定义商户主体
- 关键字段:
- `merchant_no`
- `merchant_name`
- `funds_mode`
- `status`
### 4.2 商户应用模型
- 表:`ma_merchant_app`
- 作用:商户可创建多个应用,每个应用具备独立 `app_id` / `app_secret`
- 关键字段:
- `merchant_id`
- `api_type`
- `app_id`
- `app_secret`
- `app_name`
- `status`
当前代码中,`app_id` 既是应用标识,也是外部协议鉴权入口;`epay` 兼容链路直接用它作为 `pid`
### 4.3 支付方式模型
- 表:`ma_pay_method`
- 作用:维护支付方式字典
- 当前库内数据:
- `alipay`
- `wechat`
- `unionpay`
### 4.4 支付插件模型
- 表:`ma_pay_plugin`
- 作用把“支付通道配置”与“PHP 插件实现类”解耦
- 插件需要同时实现:
- `PaymentInterface`
- `PayPluginInterface`
当前代码里已有两个插件类:
- `app/common/payment/LakalaPayment.php`
- `app/common/payment/AlipayPayment.php`
但当前数据库只注册了 `lakala`,还没有把 `alipay` 作为活动插件注册进现网开发库。
### 4.5 支付通道模型
- 表:`ma_pay_channel`
- 作用:把“商户应用 + 支付方式 + 插件 + 参数配置”绑定起来
- 关键字段:
- `merchant_id`
- `merchant_app_id`
- `plugin_code`
- `method_id`
- `config_json`
- `split_ratio`
- `chan_cost`
- `chan_mode`
- `daily_limit`
- `daily_cnt`
- `min_amount`
- `max_amount`
- `status`
- `sort`
这正对应你描述的核心业务特点:一个应用下可配置多个支付通道,每个通道可挂接不同插件与参数。
### 4.6 支付订单模型
- 表:`ma_pay_order`
- 作用:统一存放系统支付订单
- 关键特性:
- 系统订单号:`order_id`
- 商户订单号:`mch_order_no`
- 幂等唯一键:`(merchant_id, merchant_app_id, mch_order_no)`
- `extra` JSON 用于存放 `notify_url``return_url``pay_params`、退款信息等
### 4.7 回调与通知模型
- `ma_callback_inbox`:回调幂等收件箱
- `ma_pay_callback_log`:回调日志
- `ma_notify_task`:商户异步通知任务
这三张表说明项目已经为“渠道回调幂等 + 日志留痕 + 商户通知补偿”预留了比较完整的基础设施。
## 5. 代码分层与关键入口
### 外部接口入口
- `app/http/api/controller/EpayController.php`
- `app/http/api/controller/PayController.php`
### 支付主流程服务
- `app/services/api/EpayProtocolService.php`
- `app/services/api/EpayService.php`
- `app/services/PayService.php`
- `app/services/PayOrderService.php`
- `app/services/ChannelRouterService.php`
- `app/services/PluginService.php`
- `app/services/PayNotifyService.php`
- `app/services/NotifyService.php`
- `app/services/PaymentStateService.php`
### 支付插件契约
- `app/common/contracts/PaymentInterface.php`
- `app/common/contracts/PayPluginInterface.php`
- `app/common/base/BasePayment.php`
### 管理后台接口
- 商户:`MerchantController`
- 商户应用:`MerchantAppController`
- 支付方式:`PayMethodController`
- 插件注册:`PayPluginController`
- 通道:`ChannelController`
- 订单:`OrderController`
- 系统配置:`SystemController`
- 登录认证:`AuthController`
## 6. 当前已落地的对外接口
### 路由现状
当前 `app/routes/api.php` 实际挂载的对外接口为:
- `GET|POST /submit.php`
- `POST /mapi.php`
- `GET /api.php`
- `ANY /notify/{pluginCode}`
### 兼容协议现状
当前真正已打通的是 `epay` 风格接口:
- `submit.php`:页面跳转支付
- `mapi.php`API 下单
- `api.php?act=order`:查单
- `api.php?act=refund`:退款
### OpenAPI 现状
`PayController` 中存在以下方法:
- `create`
- `query`
- `close`
- `refund`
但当前都还是 `501 not implemented`,并且对应路由尚未挂载,因此“通用 OpenAPI”目前仍是预留骨架不是已上线能力。
## 7. 核心支付链路
### 7.1 Epay 下单链路
1. 商户调用 `submit.php``mapi.php`
2. `EpayProtocolService` 负责参数提取与校验
3. `EpayService` 使用 `app_secret` 做 MD5 验签
4. 构造统一内部订单数据
5. `PayOrderService` 创建订单,并通过联合唯一键保证幂等
6. `ChannelRouterService` 根据 `merchant_id + merchant_app_id + method_id` 选取通道
7. `PluginService` 从注册表解析插件类并实例化
8. 插件执行 `pay()`
9. `PayService` 回写:
- `channel_id`
- `chan_order_no`
- `chan_trade_no`
- `fee`
- `real_amount`
- `extra.pay_params`
10. 转换成 `epay` 所需返回结构给调用方
### 7.2 回调处理链路
1. 第三方渠道回调 `/notify/{pluginCode}`
2. `PayNotifyService` 调插件 `notify()` 验签与解析
3. 通过 `ma_callback_inbox` 做幂等去重
4. 状态机更新订单状态
5. 写入回调日志
6. 创建商户通知任务
### 7.3 商户通知链路
1. `NotifyService` 根据订单 `extra.notify_url` 创建通知任务
2. 通知内容写入 `ma_notify_task`
3. `sendNotify()` 使用 HTTP POST JSON 回调商户
4. 若商户返回 HTTP 200 且 body 为 `success`,视为通知成功
## 8. 插件与通道现状
### `LakalaPayment`
状态:示例插件 / mock 插件
现状:
- `pay()` 已实现,但只是返回模拟二维码字符串
- `query()` 未实现
- `close()` 未实现
- `refund()` 未实现
- `notify()` 未实现
这意味着当前库里虽然已经能“创建订单并拿到拉起参数”,但还不能完成真实的拉卡拉闭环。
### `AlipayPayment`
状态:代码层面相对完整
已实现:
- `pay()`
- `query()`
- `close()`
- `refund()`
- `notify()`
特点:
- 基于 `yansongda/pay`
- 支持产品类型:
- `alipay_web`
- `alipay_h5`
- `alipay_scan`
- `alipay_app`
- 可根据环境自动选产品
注意:
- 当前开发库没有注册 `alipay` 插件记录
- 当前通道也没有指向 `AlipayPayment`
所以它虽然写在代码里,但当前数据库并没有真正启用它。
## 9. 管理后台现状
后台已经覆盖以下核心维护能力:
- 验证码登录 + JWT 鉴权
- 商户管理
- 商户应用管理
- 支付方式管理
- 支付插件注册管理
- 支付通道管理
- 订单列表 / 详情 / 退款
- 系统基础配置管理
这部分说明“支付中心后台”已经不是空架子,而是可以承接后续运营配置的。
## 10. 当前开发库快照(基于 2026-03-13 实际查询)
### 数据量
- `ma_admin`: 1
- `ma_merchant`: 1
- `ma_merchant_app`: 1
- `ma_pay_method`: 3
- `ma_pay_plugin`: 1
- `ma_pay_channel`: 2
- `ma_pay_order`: 1
- `ma_notify_task`: 0
- `ma_callback_inbox`: 0
- `ma_pay_callback_log`: 0
### 当前商户与应用
- 商户:`M001 / 测试商户`
- 应用:`1001 / 测试应用-易支付`
- 应用类型:`epay`
### 当前活动插件
- `lakala -> app\\common\\payment\\LakalaPayment`
### 当前通道
- `lakala_alipay`
- `lakala_wechat`
### 当前示例订单
- 订单号:`P20260312160833644578`
- 商户单号:`TEST123`
- 状态:`PENDING`
- 通道:`channel_id = 1`
- `extra.pay_params` 为 mock 二维码
## 11. 当前代码与需求的对应关系
你给出的项目特点,与当前实现的对应情况如下:
### 已匹配的部分
- 多商户:已支持
- 一个商户多个应用:已支持
- 一个应用多个支付通道:已支持
- 通道可绑定支付方式:已支持
- 通道可绑定支付插件:已支持
- 通道可存储插件参数:已支持
- 通道可配置手续费:已支持,当前会参与 `fee` / `real_amount` 计算
- 商户通过 `APPID` 发起支付:已支持,当前主要在 `epay` 兼容链路中落地
- 创建订单并调用第三方插件:已支持
### 仅完成“数据建模”,尚未完全落地执行的部分
- 每日限额:字段已存在,但当前下单/路由流程未校验
- 每日笔数限制:字段已存在,但当前未校验
- 最小/最大金额限制:字段已存在,但当前未校验
- 更复杂的路由策略:当前仅按 `sort` 取第一条可用通道
- 多协议统一 OpenAPI控制器骨架存在但未真正接入
## 12. 后续阅读建议
如果下一次继续开发,建议优先从以下文件继续进入:
- 支付入口:`app/http/api/controller/EpayController.php`
- 协议适配:`app/services/api/EpayProtocolService.php`
- 业务主流程:`app/services/PayService.php`
- 订单创建:`app/services/PayOrderService.php`
- 回调处理:`app/services/PayNotifyService.php`
- 插件管理:`app/services/PluginService.php`
- 拉卡拉插件:`app/common/payment/LakalaPayment.php`
- 支付宝插件:`app/common/payment/AlipayPayment.php`
- 通道配置:`app/http/admin/controller/ChannelController.php`

320
doc/project_progress.md Normal file
View File

@@ -0,0 +1,320 @@
# MPay V2 Development Progress
更新日期2026-03-13
本文档用于记录当前项目完成度、明显缺口和建议推进顺序,方便后续继续开发时快速接手。
## 1. 当前总体判断
项目已经完成了“支付中台基础骨架 + 后台配置能力 + Epay 协议首条链路”的主体搭建。
更准确地说:
- 数据模型已经比较完整
- 后台配置能力已经具备可用性
- 支付流程主链路已经跑通到“下单并返回拉起参数”
- 真正需要继续补的是“真实渠道闭环、规则执行、异步补偿、通用协议扩展”
## 2. 已完成
### 2.1 基础框架与环境
- Webman 项目骨架已搭建
- MySQL / Redis / JWT / Cache / Event / Redis Queue 依赖已接入
- 管理后台与 API 路由已拆分
### 2.2 管理后台能力
- 验证码登录
- JWT 鉴权中间件
- 管理员信息查询
- 菜单与系统配置读取
- 商户 CRUD
- 商户应用 CRUD
- 支付方式 CRUD
- 支付插件注册 CRUD
- 支付通道 CRUD
- 订单列表 / 详情 / 后台发起退款
### 2.3 核心支付数据结构
已建表并落地:
- 商户
- 商户应用
- 支付方式
- 插件注册
- 支付通道
- 支付订单
- 回调日志
- 商户通知任务
- 回调幂等收件箱
### 2.4 下单主链路
已打通:
- Epay 参数校验
- Epay MD5 验签
- 商户应用识别
- 幂等订单创建
- 通道路由
- 插件实例化
- 插件下单
- 订单回写支付参数
- 返回兼容 Epay 的响应结构
### 2.5 支付状态基础设施
- 订单状态机服务已存在
- 成功 / 失败 / 全额退款关单状态迁移已定义
- 回调日志记录能力已存在
- 回调幂等收件箱已存在
- 商户通知任务创建逻辑已存在
### 2.6 插件体系
已建立统一插件契约:
- `PaymentInterface`
- `PayPluginInterface`
- `BasePayment`
说明后续继续接入新渠道时,整体扩展方式已经明确。
## 3. 部分完成
### 3.1 Epay 兼容是“主路径”,但还不是“全量兼容”
当前已实现:
- `submit.php`
- `mapi.php`
- `api.php?act=order`
- `api.php?act=refund`
`doc/epay.md` 中提到的一些能力,如 `query``settle``orders` 等,代码中暂未实现。
### 3.2 支付宝插件代码较完整,但未在当前数据库启用
现状:
- `AlipayPayment.php` 已实现
- 当前开发库 `ma_pay_plugin` 中只有 `lakala`
这意味着支付宝更多处于“代码已写好、配置未接入”的状态。
### 3.3 回调后通知商户的基础逻辑存在,但补偿闭环还不完整
已完成:
- 创建通知任务
- 发送通知
- 失败重试时间计算
待确认 / 待补齐:
- 当前没有看到明确的任务投递入口
- 也没有看到定时调度 `NotifyMerchantJob` 的配置闭环
- `NotifyMerchantJob` 虽然存在,但尚未形成明确的可运行消费链路
更保守地说,商户通知补偿链路还没有真正闭环。
## 4. 待完成
### 4.1 通用 OpenAPI
当前状态:
- `PayController` 只有骨架
- `create/query/close/refund` 都返回 `501`
- `OpenApiAuthMiddleware` 已存在,但未挂到路由
建议判断:这是下一阶段最适合补完的能力之一。
### 4.2 拉卡拉真实对接
当前状态:
- `LakalaPayment::pay()` 只返回 mock 二维码
- `query/close/refund/notify` 全部未实现
影响:
- 现在只能用于打通订单创建流程
- 还不能进行真实线上支付联调
### 4.3 通道路由规则执行
数据库已设计的字段很多,但运行期并未全部生效:
- `daily_limit` 未校验
- `daily_cnt` 未校验
- `min_amount` 未校验
- `max_amount` 未校验
- `split_ratio` 当前只存储,未看到清算分账逻辑
- 路由策略目前只是“按排序取第一条可用通道”
这块是项目从“能下单”走向“可运营”的关键缺口。
### 4.4 Epay 协议映射细节
当前内部支付方式代码使用:
- `alipay`
- `wechat`
但传统 Epay 常见值通常还有:
- `wxpay`
- `qqpay`
当前代码里没有看到统一别名映射层,说明“协议兼容”仍偏接口形态兼容,而不是完整字段语义兼容。
### 4.5 插件注册与初始化数据同步
代码、SQL、数据库现状存在轻微偏差
- `database/dev_seed.sql` 里准备了 `alipay``lakala`
- 当前开发库只看到 `lakala`
建议后续把“代码存在但数据库未启用”的状态统一起来,减少联调歧义。
### 4.6 通道安全与敏感配置
当前通道配置直接存在 `config_json` 中,后续建议补充:
- 敏感字段加密存储
- 后台展示脱敏
- 配置变更审计日志
### 4.7 测试体系
当前仓库里没有看到成体系的:
- 单元测试
- 协议测试
- 插件对接测试
- 回调幂等测试
- 退款回归测试
这会让后续迭代的回归成本越来越高。
## 5. 风险与注意点
### 5.1 当前“多通道”能力更偏配置层,而不是调度层
虽然表结构和后台已经支持多通道,但运行时路由还比较简单,不能完全体现:
- 限额控制
- 金额区间控制
- 通道健康度切换
- 优先级与容灾
### 5.2 退款能力目前偏基础版
当前退款服务已存在,但从实现上看:
- 更适合单次退款 / 全额退款
- 全额退款后直接把订单关闭
- 没有独立退款单模型
- 没有完整的部分退款累计能力
### 5.3 回调成功后的订单与通知一致性要继续加强
当前已经有:
- 幂等收件箱
- 状态机
- 通知任务表
这是很好的基础。
但真正生产级还建议再补:
- 事务边界说明
- 异常重放工具
- 回调人工补单工具
- 通知签名
## 6. 建议优先级
### P0优先补完直接影响可用性
1. 实现真实渠道插件,至少先补完一个可联调通道
2. 补完 OpenAPI 主链路
3. 在路由阶段执行金额限制 / 限额 / 笔数规则
4. 打通商户通知任务的实际调度与重试闭环
### P1补齐可运营能力
1. 增加支付方式别名映射,提升 Epay 兼容度
2.`AlipayPayment` 正式接入插件注册与通道配置
3. 增加后台对通道能力、产品、环境的可视化说明
4. 增加日志检索与问题排查手段
### P2走向平台化
1. 增加更多协议兼容层
2. 增加清算 / 分账 / 对账
3. 增加风控规则
4. 增加监控、告警、报表
## 7. 建议后续开发方向
### 方向一:先做“一个真实可用通道”
建议优先把某一个通道做成完整闭环:
- 下单
- 回调
- 查单
- 关单
- 退款
这样项目就能从“框架完成”升级为“真实可上线联调”。
### 方向二:补通用 OpenAPI
原因:
- 你已经明确后续可能兼容更多接口
- 当前通用控制器和鉴权中间件已经有雏形
- 补完之后,项目会从“单协议适配器”升级为“统一支付网关”
### 方向三:把通道路由做成真正的策略引擎
建议把下面这些字段从“仅存储”升级为“真实执行”:
- 金额范围
- 单日限额
- 单日限笔
- 通道优先级
- 通道健康状态
- 权重或降级策略
### 方向四:补测试与排障工具
优先建议增加:
- 下单幂等测试
- 回调幂等测试
- 退款状态测试
- 协议字段兼容测试
- 一键重发通知工具
## 8. 推荐继续开发顺序
如果下一次直接继续往下做,我建议按这个顺序推进:
1. 选定一个真实渠道作为首个闭环目标
2. 补完该插件的 `notify/query/refund/close`
3. 接入并验证商户通知补偿链路
4.`ChannelRouterService` 前后补齐通道规则校验
5. 正式实现 `PayController`
6. 抽象协议适配层,准备支持更多接口
7. 增加测试与后台排障能力
## 9. 当前一句话结论
这是一个“骨架已经成型、第一条协议已打通、非常适合继续往生产级推进”的支付中台项目;下一阶段的重点不是重写,而是把已有设计真正补成闭环。