mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-04-21 09:24:33 +08:00
codex基础代码更新
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
522
app/http/admin/controller/FinanceController.php
Normal file
522
app/http/admin/controller/FinanceController.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?: '-',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user