From f3919c989970b74c911cabe7ce031a95ade8edc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8A=80=E6=9C=AF=E8=80=81=E8=83=A1?= <1094551889@qq.com> Date: Fri, 20 Mar 2026 10:31:13 +0800 Subject: [PATCH] =?UTF-8?q?codex=E5=9F=BA=E7=A1=80=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/common/contracts/PaymentInterface.php | 5 + app/common/payment/AlipayPayment.php | 10 + app/common/payment/LakalaPayment.php | 11 +- .../admin/controller/ChannelController.php | 681 ++++++++++++--- .../admin/controller/FinanceController.php | 522 +++++++++++ .../controller/MerchantAppController.php | 210 ++++- .../admin/controller/MerchantController.php | 810 +++++++++++++++++- app/http/admin/controller/OrderController.php | 258 +++++- .../admin/controller/SystemController.php | 318 ++++++- app/http/api/controller/EpayController.php | 79 +- app/http/api/controller/PayController.php | 49 +- app/models/CallbackInbox.php | 34 + app/repositories/CallbackInboxRepository.php | 43 + app/repositories/PaymentChannelRepository.php | 51 +- app/repositories/PaymentOrderRepository.php | 106 ++- app/routes/admin.php | 280 ++++-- app/routes/api.php | 6 +- app/services/ChannelRoutePolicyService.php | 103 +++ app/services/ChannelRouterService.php | 601 ++++++++++++- app/services/PayNotifyService.php | 199 +++++ app/services/PayOrderService.php | 8 +- app/services/PayService.php | 62 +- app/services/PaymentStateService.php | 113 +++ app/services/api/EpayProtocolService.php | 106 +++ app/services/api/EpayService.php | 2 +- config/base-config/basic.json | 13 +- config/base-config/email.json | 37 +- config/base-config/permission.json | 7 +- config/base-config/tabs.json | 15 +- database/patch_callback_inbox.sql | 20 + doc/auth_strategy_design.md | 145 ---- doc/order_table_design.md | 214 ----- doc/payment_flow.md | 485 ----------- doc/payment_system_implementation.md | 182 ---- doc/project_overview.md | 414 +++++++++ doc/project_progress.md | 320 +++++++ 36 files changed, 5060 insertions(+), 1459 deletions(-) create mode 100644 app/http/admin/controller/FinanceController.php create mode 100644 app/models/CallbackInbox.php create mode 100644 app/repositories/CallbackInboxRepository.php create mode 100644 app/services/ChannelRoutePolicyService.php create mode 100644 app/services/PayNotifyService.php create mode 100644 app/services/PaymentStateService.php create mode 100644 app/services/api/EpayProtocolService.php create mode 100644 database/patch_callback_inbox.sql delete mode 100644 doc/auth_strategy_design.md delete mode 100644 doc/order_table_design.md delete mode 100644 doc/payment_flow.md delete mode 100644 doc/payment_system_implementation.md create mode 100644 doc/project_overview.md create mode 100644 doc/project_progress.md diff --git a/app/common/contracts/PaymentInterface.php b/app/common/contracts/PaymentInterface.php index 9836b77..1de889a 100644 --- a/app/common/contracts/PaymentInterface.php +++ b/app/common/contracts/PaymentInterface.php @@ -6,6 +6,7 @@ namespace app\common\contracts; use app\exceptions\PaymentException; use support\Request; +use support\Response; /** * 支付插件接口 @@ -81,4 +82,8 @@ interface PaymentInterface * @throws PaymentException 验签失败、数据异常等 */ public function notify(Request $request): array; + + public function notifySuccess(): string|Response; + + public function notifyFail(): string|Response; } diff --git a/app/common/payment/AlipayPayment.php b/app/common/payment/AlipayPayment.php index b2e014e..38f42d2 100644 --- a/app/common/payment/AlipayPayment.php +++ b/app/common/payment/AlipayPayment.php @@ -9,6 +9,7 @@ use app\common\contracts\PaymentInterface; use app\exceptions\PaymentException; use Psr\Http\Message\ResponseInterface; use support\Request; +use support\Response; use Yansongda\Pay\Pay; use Yansongda\Supports\Collection; @@ -267,4 +268,13 @@ class AlipayPayment extends BasePayment implements PaymentInterface throw new PaymentException('支付宝回调验签失败:' . $e->getMessage(), 402); } } + public function notifySuccess(): string|Response + { + return 'success'; + } + + public function notifyFail(): string|Response + { + return 'fail'; + } } diff --git a/app/common/payment/LakalaPayment.php b/app/common/payment/LakalaPayment.php index 20299f7..ddcdb3b 100644 --- a/app/common/payment/LakalaPayment.php +++ b/app/common/payment/LakalaPayment.php @@ -7,6 +7,7 @@ use app\common\base\BasePayment; use app\common\contracts\PaymentInterface; use app\exceptions\PaymentException; use support\Request; +use support\Response; /** * 拉卡拉支付插件(最小可用示例) @@ -74,5 +75,13 @@ class LakalaPayment extends BasePayment implements PaymentInterface { throw new PaymentException('LakalaPayment::notify 暂未实现', 402); } -} + public function notifySuccess(): string|Response + { + return 'success'; + } + public function notifyFail(): string|Response + { + return 'fail'; + } +} diff --git a/app/http/admin/controller/ChannelController.php b/app/http/admin/controller/ChannelController.php index 90c3e2a..f80b243 100644 --- a/app/http/admin/controller/ChannelController.php +++ b/app/http/admin/controller/ChannelController.php @@ -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; + } +} \ No newline at end of file diff --git a/app/http/admin/controller/FinanceController.php b/app/http/admin/controller/FinanceController.php new file mode 100644 index 0000000..41c2e7c --- /dev/null +++ b/app/http/admin/controller/FinanceController.php @@ -0,0 +1,522 @@ +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', + }; + } +} diff --git a/app/http/admin/controller/MerchantAppController.php b/app/http/admin/controller/MerchantAppController.php index 6b86df5..cbea53f 100644 --- a/app/http/admin/controller/MerchantAppController.php +++ b/app/http/admin/controller/MerchantAppController.php @@ -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; + } +} diff --git a/app/http/admin/controller/MerchantController.php b/app/http/admin/controller/MerchantController.php index d043ef3..c82d675 100644 --- a/app/http/admin/controller/MerchantController.php +++ b/app/http/admin/controller/MerchantController.php @@ -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 : []; } } - diff --git a/app/http/admin/controller/OrderController.php b/app/http/admin/controller/OrderController.php index 110bab1..91fdb24 100644 --- a/app/http/admin/controller/OrderController.php +++ b/app/http/admin/controller/OrderController.php @@ -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 ?: '-', + }; + } } diff --git a/app/http/admin/controller/SystemController.php b/app/http/admin/controller/SystemController.php index 427724f..96a0f23 100644 --- a/app/http/admin/controller/SystemController.php +++ b/app/http/admin/controller/SystemController.php @@ -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); } } - diff --git a/app/http/api/controller/EpayController.php b/app/http/api/controller/EpayController.php index b000dea..a088ce2 100644 --- a/app/http/api/controller/EpayController.php +++ b/app/http/api/controller/EpayController.php @@ -3,8 +3,7 @@ namespace app\http\api\controller; use app\common\base\BaseController; -use app\services\api\EpayService; -use app\validation\EpayValidator; +use app\services\api\EpayProtocolService; use support\Request; use support\Response; @@ -14,43 +13,32 @@ use support\Response; class EpayController extends BaseController { public function __construct( - protected EpayService $epayService - ) {} + protected EpayProtocolService $epayProtocolService + ) { + } /** * 页面跳转支付 */ public function submit(Request $request) { - $data = match ($request->method()) { - 'GET' => $request->get(), - 'POST' => $request->post(), - default => $request->all(), - }; - try { - // 参数校验(使用自定义 Validator + 场景) - $params = EpayValidator::make($data) - ->withScene('submit') - ->validate(); + $result = $this->epayProtocolService->handleSubmit($request); + $type = $result['response_type'] ?? ''; - // 业务处理:创建订单并获取支付参数 - $result = $this->epayService->submit($params, $request); - $payParams = $result['pay_params'] ?? []; - - // 根据支付参数类型返回响应 - if (($payParams['type'] ?? '') === 'redirect' && !empty($payParams['url'])) { - return redirect($payParams['url']); + if ($type === 'redirect' && !empty($result['url'])) { + return redirect($result['url']); } - if (($payParams['type'] ?? '') === 'form') { - if (!empty($payParams['html'])) { - return response($payParams['html'])->withHeaders(['Content-Type' => 'text/html; charset=UTF-8']); - } - return $this->renderForm($payParams); + if ($type === 'form_html') { + return response((string)($result['html'] ?? '')) + ->withHeaders(['Content-Type' => 'text/html; charset=UTF-8']); + } + + if ($type === 'form_params') { + return $this->renderForm((array)($result['form'] ?? [])); } - // 如果没有匹配的类型,返回错误 return $this->fail('支付参数生成失败'); } catch (\Throwable $e) { return $this->fail($e->getMessage()); @@ -62,20 +50,12 @@ class EpayController extends BaseController */ public function mapi(Request $request) { - $data = $request->post(); - try { - $params = EpayValidator::make($data) - ->withScene('mapi') - ->validate(); - - $result = $this->epayService->mapi($params, $request); - - return json($result); + return json($this->epayProtocolService->handleMapi($request)); } catch (\Throwable $e) { return json([ 'code' => 0, - 'msg' => $e->getMessage(), + 'msg' => $e->getMessage(), ]); } } @@ -85,33 +65,12 @@ class EpayController extends BaseController */ public function api(Request $request) { - $data = array_merge($request->get(), $request->post()); - try { - $act = strtolower($data['act'] ?? ''); - - if ($act === 'order') { - $params = EpayValidator::make($data) - ->withScene('api_order') - ->validate(); - $result = $this->epayService->api($params); - } elseif ($act === 'refund') { - $params = EpayValidator::make($data) - ->withScene('api_refund') - ->validate(); - $result = $this->epayService->api($params); - } else { - $result = [ - 'code' => 0, - 'msg' => '不支持的操作类型', - ]; - } - - return json($result); + return json($this->epayProtocolService->handleApi($request)); } catch (\Throwable $e) { return json([ 'code' => 0, - 'msg' => $e->getMessage(), + 'msg' => $e->getMessage(), ]); } } diff --git a/app/http/api/controller/PayController.php b/app/http/api/controller/PayController.php index 5a0e4fe..35670cf 100644 --- a/app/http/api/controller/PayController.php +++ b/app/http/api/controller/PayController.php @@ -3,8 +3,10 @@ namespace app\http\api\controller; use app\common\base\BaseController; -use app\services\PayOrderService; +use app\services\PayNotifyService; +use app\services\PluginService; use support\Request; +use support\Response; /** * 支付控制器(OpenAPI) @@ -12,30 +14,61 @@ use support\Request; class PayController extends BaseController { public function __construct( - protected PayOrderService $payOrderService - ) {} + protected PayNotifyService $payNotifyService, + protected PluginService $pluginService + ) { + } /** * 创建订单 */ - public function create(Request $request) {} + public function create(Request $request) + { + return $this->fail('not implemented', 501); + } /** * 查询订单 */ - public function query(Request $request) {} + public function query(Request $request) + { + return $this->fail('not implemented', 501); + } /** * 关闭订单 */ - public function close(Request $request) {} + public function close(Request $request) + { + return $this->fail('not implemented', 501); + } /** * 订单退款 */ - public function refund(Request $request) {} + public function refund(Request $request) + { + return $this->fail('not implemented', 501); + } + /** * 异步通知 */ - public function notify(Request $request) {} + public function notify(Request $request, string $pluginCode) + { + try { + $plugin = $this->pluginService->getPluginInstance($pluginCode); + $result = $this->payNotifyService->handleNotify($pluginCode, $request); + $ackSuccess = method_exists($plugin, 'notifySuccess') ? $plugin->notifySuccess() : 'success'; + $ackFail = method_exists($plugin, 'notifyFail') ? $plugin->notifyFail() : 'fail'; + + if (!($result['ok'] ?? false)) { + return $ackFail instanceof Response ? $ackFail : response((string)$ackFail); + } + + return $ackSuccess instanceof Response ? $ackSuccess : response((string)$ackSuccess); + } catch (\Throwable $e) { + return response('fail'); + } + } } diff --git a/app/models/CallbackInbox.php b/app/models/CallbackInbox.php new file mode 100644 index 0000000..c3fba1f --- /dev/null +++ b/app/models/CallbackInbox.php @@ -0,0 +1,34 @@ + 'array', + 'process_status' => 'integer', + 'processed_at' => 'datetime', + ]; +} + diff --git a/app/repositories/CallbackInboxRepository.php b/app/repositories/CallbackInboxRepository.php new file mode 100644 index 0000000..209b63e --- /dev/null +++ b/app/repositories/CallbackInboxRepository.php @@ -0,0 +1,43 @@ +model->newQuery() + ->where('event_key', $eventKey) + ->first(); + } + + /** + * 尝试创建幂等事件,重复时返回 false。 + */ + public function createIfAbsent(array $data): bool + { + try { + $this->model->newQuery()->create($data); + return true; + } catch (QueryException $e) { + // 1062: duplicate entry + if ((int)($e->errorInfo[1] ?? 0) === 1062) { + return false; + } + throw $e; + } + } +} + diff --git a/app/repositories/PaymentChannelRepository.php b/app/repositories/PaymentChannelRepository.php index a3fc5c9..bc579de 100644 --- a/app/repositories/PaymentChannelRepository.php +++ b/app/repositories/PaymentChannelRepository.php @@ -5,9 +5,6 @@ namespace app\repositories; use app\common\base\BaseRepository; use app\models\PaymentChannel; -/** - * 支付通道仓储 - */ class PaymentChannelRepository extends BaseRepository { public function __construct() @@ -15,9 +12,6 @@ class PaymentChannelRepository extends BaseRepository parent::__construct(new PaymentChannel()); } - /** - * 根据商户、应用、支付方式查找可用通道 - */ public function findAvailableChannel(int $merchantId, int $merchantAppId, int $methodId): ?PaymentChannel { return $this->model->newQuery() @@ -35,4 +29,49 @@ class PaymentChannelRepository extends BaseRepository ->where('chan_code', $chanCode) ->first(); } + + public function searchPaginate(array $filters = [], int $page = 1, int $pageSize = 10) + { + $query = $this->buildSearchQuery($filters); + $query->orderBy('sort', 'asc')->orderByDesc('id'); + + return $query->paginate($pageSize, ['*'], 'page', $page); + } + + public function searchList(array $filters = []) + { + return $this->buildSearchQuery($filters) + ->orderBy('sort', 'asc') + ->orderByDesc('id') + ->get(); + } + + private function buildSearchQuery(array $filters = []) + { + $query = $this->model->newQuery(); + + if (!empty($filters['merchant_id'])) { + $query->where('merchant_id', (int)$filters['merchant_id']); + } + if (!empty($filters['merchant_app_id'])) { + $query->where('merchant_app_id', (int)$filters['merchant_app_id']); + } + if (!empty($filters['method_id'])) { + $query->where('method_id', (int)$filters['method_id']); + } + if (($filters['status'] ?? '') !== '' && $filters['status'] !== null) { + $query->where('status', (int)$filters['status']); + } + if (!empty($filters['plugin_code'])) { + $query->where('plugin_code', (string)$filters['plugin_code']); + } + if (!empty($filters['chan_code'])) { + $query->where('chan_code', 'like', '%' . $filters['chan_code'] . '%'); + } + if (!empty($filters['chan_name'])) { + $query->where('chan_name', 'like', '%' . $filters['chan_name'] . '%'); + } + + return $query; + } } diff --git a/app/repositories/PaymentOrderRepository.php b/app/repositories/PaymentOrderRepository.php index 9bef819..1281abc 100644 --- a/app/repositories/PaymentOrderRepository.php +++ b/app/repositories/PaymentOrderRepository.php @@ -10,6 +10,11 @@ use app\models\PaymentOrder; */ class PaymentOrderRepository extends BaseRepository { + public const STATUS_PENDING = 0; + public const STATUS_SUCCESS = 1; + public const STATUS_FAIL = 2; + public const STATUS_CLOSED = 3; + public function __construct() { parent::__construct(new PaymentOrder()); @@ -58,6 +63,82 @@ class PaymentOrderRepository extends BaseRepository * 后台订单列表:支持筛选与模糊搜索 */ public function searchPaginate(array $filters = [], int $page = 1, int $pageSize = 10) + { + $query = $this->buildSearchQuery($filters); + $query->orderByDesc('id'); + return $query->paginate($pageSize, ['*'], 'page', $page); + } + + public function searchList(array $filters = [], int $limit = 5000) + { + return $this->buildSearchQuery($filters) + ->orderByDesc('id') + ->limit($limit) + ->get(); + } + + public function aggregateByChannel(array $channelIds = [], array $filters = []): array + { + if (empty($channelIds)) { + return []; + } + + $query = $this->model->newQuery() + ->selectRaw( + 'channel_id, + COUNT(*) AS total_orders, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_orders, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS pending_orders, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS fail_orders, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS closed_orders, + COALESCE(SUM(amount), 0) AS total_amount, + COALESCE(SUM(CASE WHEN status = ? THEN amount ELSE 0 END), 0) AS success_amount, + SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) AS today_orders, + COALESCE(SUM(CASE WHEN DATE(created_at) = CURDATE() THEN amount ELSE 0 END), 0) AS today_amount, + SUM(CASE WHEN DATE(created_at) = CURDATE() AND status = ? THEN 1 ELSE 0 END) AS today_success_orders, + COALESCE(SUM(CASE WHEN DATE(created_at) = CURDATE() AND status = ? THEN amount ELSE 0 END), 0) AS today_success_amount, + MAX(created_at) AS last_order_at, + MAX(CASE WHEN status = ? THEN pay_at ELSE NULL END) AS last_success_at', + [ + self::STATUS_SUCCESS, + self::STATUS_PENDING, + self::STATUS_FAIL, + self::STATUS_CLOSED, + self::STATUS_SUCCESS, + self::STATUS_SUCCESS, + self::STATUS_SUCCESS, + self::STATUS_SUCCESS, + ] + ) + ->whereIn('channel_id', $channelIds); + + if (!empty($filters['merchant_id'])) { + $query->where('merchant_id', (int)$filters['merchant_id']); + } + if (!empty($filters['merchant_app_id'])) { + $query->where('merchant_app_id', (int)$filters['merchant_app_id']); + } + if (!empty($filters['method_id'])) { + $query->where('method_id', (int)$filters['method_id']); + } + if (!empty($filters['created_from'])) { + $query->where('created_at', '>=', $filters['created_from']); + } + if (!empty($filters['created_to'])) { + $query->where('created_at', '<=', $filters['created_to']); + } + + $rows = $query->groupBy('channel_id')->get(); + + $result = []; + foreach ($rows as $row) { + $result[(int)$row->channel_id] = $row->toArray(); + } + + return $result; + } + + private function buildSearchQuery(array $filters = []) { $query = $this->model->newQuery(); @@ -73,6 +154,28 @@ class PaymentOrderRepository extends BaseRepository if (!empty($filters['channel_id'])) { $query->where('channel_id', (int)$filters['channel_id']); } + if (!empty($filters['route_policy_name'])) { + $query->whereRaw( + "JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.policy.policy_name')) like ?", + ['%' . $filters['route_policy_name'] . '%'] + ); + } + if (($filters['route_state'] ?? '') !== '' && $filters['route_state'] !== null) { + $routeState = (string)$filters['route_state']; + if ($routeState === 'error') { + $query->whereRaw("JSON_EXTRACT(extra, '$.route_error') IS NOT NULL"); + } elseif ($routeState === 'none') { + $query->whereRaw("JSON_EXTRACT(extra, '$.route_error') IS NULL"); + $query->whereRaw( + "(JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.source')) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.source')) = '')" + ); + } else { + $query->whereRaw( + "JSON_EXTRACT(extra, '$.route_error') IS NULL AND JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.source')) = ?", + [$routeState] + ); + } + } if (($filters['status'] ?? '') !== '' && $filters['status'] !== null) { $query->where('status', (int)$filters['status']); } @@ -89,7 +192,6 @@ class PaymentOrderRepository extends BaseRepository $query->where('created_at', '<=', $filters['created_to']); } - $query->orderByDesc('id'); - return $query->paginate($pageSize, ['*'], 'page', $page); + return $query; } } diff --git a/app/routes/admin.php b/app/routes/admin.php index 67667ea..a92f6ba 100644 --- a/app/routes/admin.php +++ b/app/routes/admin.php @@ -1,84 +1,230 @@ -name('captcha')->setParams(['real_name' => '验证码']); - Route::post('/login', [AuthController::class, 'login'])->name('login')->setParams(['real_name' => '登录']); + Route::get('/captcha', [AuthController::class, 'captcha']) + ->name('captcha') + ->setParams(['real_name' => 'adminCaptcha']); + Route::post('/login', [AuthController::class, 'login']) + ->name('login') + ->setParams(['real_name' => 'adminLogin']); - // 需要认证的路由组 Route::group('', function () { - // 用户相关(需要JWT验证) - Route::get('/user/getUserInfo', [AdminController::class, 'getUserInfo'])->name('getUserInfo')->setParams(['real_name' => '获取管理员信息']); - - // 菜单相关(需要JWT验证) - Route::get('/menu/getRouters', [MenuController::class, 'getRouters'])->name('getRouters')->setParams(['real_name' => '获取菜单']); - - // 系统相关(需要JWT验证) - Route::get('/system/getDict[/{code}]', [SystemController::class, 'getDict'])->name('getDict')->setParams(['real_name' => '获取字典']); - - // 系统配置相关(需要JWT验证) - Route::get('/system/base-config/tabs', [SystemController::class, 'getTabsConfig'])->name('getTabsConfig')->setParams(['real_name' => '获取系统配置tabs']); - Route::get('/system/base-config/form/{tabKey}', [SystemController::class, 'getFormConfig'])->name('getFormConfig')->setParams(['real_name' => '获取系统配置form']); - Route::post('/system/base-config/submit/{tabKey}', [SystemController::class, 'submitConfig'])->name('submitConfig')->setParams(['real_name' => '提交系统配置']); - - // 通道管理相关(需要JWT验证) - Route::get('/channel/list', [ChannelController::class, 'list'])->name('list')->setParams(['real_name' => '获取通道列表']); - Route::get('/channel/detail', [ChannelController::class, 'detail'])->name('detail')->setParams(['real_name' => '获取通道详情']); - Route::post('/channel/save', [ChannelController::class, 'save'])->name('save')->setParams(['real_name' => '保存通道']); - - // 插件管理相关(需要JWT验证) - Route::get('/channel/plugins', [PluginController::class, 'plugins'])->name('plugins')->setParams(['real_name' => '获取插件列表']); - Route::get('/channel/plugin/config-schema', [PluginController::class, 'configSchema'])->name('configSchema')->setParams(['real_name' => '获取插件配置schema']); - Route::get('/channel/plugin/products', [PluginController::class, 'products'])->name('products')->setParams(['real_name' => '获取插件产品列表']); + Route::get('/user/getUserInfo', [AdminController::class, 'getUserInfo']) + ->name('getUserInfo') + ->setParams(['real_name' => 'getUserInfo']); + Route::get('/menu/getRouters', [MenuController::class, 'getRouters']) + ->name('getRouters') + ->setParams(['real_name' => 'getRouters']); - // 商户管理 - Route::get('/merchant/list', [MerchantController::class, 'list'])->name('merchantList')->setParams(['real_name' => '商户列表']); - Route::get('/merchant/detail', [MerchantController::class, 'detail'])->name('merchantDetail')->setParams(['real_name' => '商户详情']); - Route::post('/merchant/save', [MerchantController::class, 'save'])->name('merchantSave')->setParams(['real_name' => '保存商户']); - Route::post('/merchant/toggle', [MerchantController::class, 'toggle'])->name('merchantToggle')->setParams(['real_name' => '启用禁用商户']); + Route::get('/system/getDict[/{code}]', [SystemController::class, 'getDict']) + ->name('getDict') + ->setParams(['real_name' => 'getSystemDict']); + Route::get('/system/base-config/tabs', [SystemController::class, 'getTabsConfig']) + ->name('getTabsConfig') + ->setParams(['real_name' => 'getSystemTabs']); + Route::get('/system/base-config/form/{tabKey}', [SystemController::class, 'getFormConfig']) + ->name('getFormConfig') + ->setParams(['real_name' => 'getSystemForm']); + Route::post('/system/base-config/submit/{tabKey}', [SystemController::class, 'submitConfig']) + ->name('submitConfig') + ->setParams(['real_name' => 'submitSystemConfig']); + Route::get('/system/log/files', [SystemController::class, 'logFiles']) + ->name('systemLogFiles') + ->setParams(['real_name' => 'systemLogFiles']); + Route::get('/system/log/summary', [SystemController::class, 'logSummary']) + ->name('systemLogSummary') + ->setParams(['real_name' => 'systemLogSummary']); + Route::get('/system/log/content', [SystemController::class, 'logContent']) + ->name('systemLogContent') + ->setParams(['real_name' => 'systemLogContent']); + Route::get('/system/notice/overview', [SystemController::class, 'noticeOverview']) + ->name('systemNoticeOverview') + ->setParams(['real_name' => 'systemNoticeOverview']); + Route::post('/system/notice/test', [SystemController::class, 'noticeTest']) + ->name('systemNoticeTest') + ->setParams(['real_name' => 'systemNoticeTest']); - // 商户应用管理 - Route::get('/merchant-app/list', [MerchantAppController::class, 'list'])->name('merchantAppList')->setParams(['real_name' => '商户应用列表']); - Route::get('/merchant-app/detail', [MerchantAppController::class, 'detail'])->name('merchantAppDetail')->setParams(['real_name' => '商户应用详情']); - Route::post('/merchant-app/save', [MerchantAppController::class, 'save'])->name('merchantAppSave')->setParams(['real_name' => '保存商户应用']); - Route::post('/merchant-app/reset-secret', [MerchantAppController::class, 'resetSecret'])->name('merchantAppResetSecret')->setParams(['real_name' => '重置应用密钥']); - Route::post('/merchant-app/toggle', [MerchantAppController::class, 'toggle'])->name('merchantAppToggle')->setParams(['real_name' => '启用禁用商户应用']); + Route::get('/finance/reconciliation', [FinanceController::class, 'reconciliation']) + ->name('financeReconciliation') + ->setParams(['real_name' => 'financeReconciliation']); + Route::get('/finance/settlement', [FinanceController::class, 'settlement']) + ->name('financeSettlement') + ->setParams(['real_name' => 'financeSettlement']); + Route::get('/finance/batch-settlement', [FinanceController::class, 'batchSettlement']) + ->name('financeBatchSettlement') + ->setParams(['real_name' => 'financeBatchSettlement']); + Route::get('/finance/settlement-record', [FinanceController::class, 'settlementRecord']) + ->name('financeSettlementRecord') + ->setParams(['real_name' => 'financeSettlementRecord']); + Route::get('/finance/split', [FinanceController::class, 'split']) + ->name('financeSplit') + ->setParams(['real_name' => 'financeSplit']); + Route::get('/finance/fee', [FinanceController::class, 'fee']) + ->name('financeFee') + ->setParams(['real_name' => 'financeFee']); + Route::get('/finance/invoice', [FinanceController::class, 'invoice']) + ->name('financeInvoice') + ->setParams(['real_name' => 'financeInvoice']); - // 支付方式管理 - Route::get('/pay-method/list', [PayMethodController::class, 'list'])->name('payMethodList')->setParams(['real_name' => '支付方式列表']); - Route::post('/pay-method/save', [PayMethodController::class, 'save'])->name('payMethodSave')->setParams(['real_name' => '保存支付方式']); - Route::post('/pay-method/toggle', [PayMethodController::class, 'toggle'])->name('payMethodToggle')->setParams(['real_name' => '启用禁用支付方式']); + Route::get('/channel/list', [ChannelController::class, 'list']) + ->name('channelList') + ->setParams(['real_name' => 'channelList']); + Route::get('/channel/detail', [ChannelController::class, 'detail']) + ->name('channelDetail') + ->setParams(['real_name' => 'channelDetail']); + Route::get('/channel/monitor', [ChannelController::class, 'monitor']) + ->name('channelMonitor') + ->setParams(['real_name' => 'channelMonitor']); + Route::get('/channel/polling', [ChannelController::class, 'polling']) + ->name('channelPolling') + ->setParams(['real_name' => 'channelPolling']); + Route::get('/channel/policy/list', [ChannelController::class, 'policyList']) + ->name('channelPolicyList') + ->setParams(['real_name' => 'channelPolicyList']); + Route::post('/channel/save', [ChannelController::class, 'save']) + ->name('channelSave') + ->setParams(['real_name' => 'channelSave']); + Route::post('/channel/toggle', [ChannelController::class, 'toggle']) + ->name('channelToggle') + ->setParams(['real_name' => 'channelToggle']); + Route::post('/channel/policy/save', [ChannelController::class, 'policySave']) + ->name('channelPolicySave') + ->setParams(['real_name' => 'channelPolicySave']); + Route::post('/channel/policy/preview', [ChannelController::class, 'policyPreview']) + ->name('channelPolicyPreview') + ->setParams(['real_name' => 'channelPolicyPreview']); + Route::post('/channel/policy/delete', [ChannelController::class, 'policyDelete']) + ->name('channelPolicyDelete') + ->setParams(['real_name' => 'channelPolicyDelete']); - // 插件注册表管理 - Route::get('/pay-plugin/list', [PayPluginController::class, 'list'])->name('payPluginList')->setParams(['real_name' => '支付插件注册表列表']); - Route::post('/pay-plugin/save', [PayPluginController::class, 'save'])->name('payPluginSave')->setParams(['real_name' => '保存支付插件注册表']); - Route::post('/pay-plugin/toggle', [PayPluginController::class, 'toggle'])->name('payPluginToggle')->setParams(['real_name' => '启用禁用支付插件']); + Route::get('/channel/plugins', [PluginController::class, 'plugins']) + ->name('channelPlugins') + ->setParams(['real_name' => 'channelPlugins']); + Route::get('/channel/plugin/config-schema', [PluginController::class, 'configSchema']) + ->name('channelPluginConfigSchema') + ->setParams(['real_name' => 'channelPluginConfigSchema']); + Route::get('/channel/plugin/products', [PluginController::class, 'products']) + ->name('channelPluginProducts') + ->setParams(['real_name' => 'channelPluginProducts']); - // 订单管理 - Route::get('/order/list', [OrderController::class, 'list'])->name('orderList')->setParams(['real_name' => '订单列表']); - Route::get('/order/detail', [OrderController::class, 'detail'])->name('orderDetail')->setParams(['real_name' => '订单详情']); - Route::post('/order/refund', [OrderController::class, 'refund'])->name('orderRefund')->setParams(['real_name' => '订单退款']); + Route::get('/merchant/list', [MerchantController::class, 'list']) + ->name('merchantList') + ->setParams(['real_name' => 'merchantList']); + Route::get('/merchant/detail', [MerchantController::class, 'detail']) + ->name('merchantDetail') + ->setParams(['real_name' => 'merchantDetail']); + Route::get('/merchant/profile/detail', [MerchantController::class, 'profileDetail']) + ->name('merchantProfileDetail') + ->setParams(['real_name' => 'merchantProfileDetail']); + Route::get('/merchant/statistics', [MerchantController::class, 'statistics']) + ->name('merchantStatistics') + ->setParams(['real_name' => 'merchantStatistics']); + Route::get('/merchant/funds', [MerchantController::class, 'funds']) + ->name('merchantFunds') + ->setParams(['real_name' => 'merchantFunds']); + Route::get('/merchant/audit', [MerchantController::class, 'audit']) + ->name('merchantAudit') + ->setParams(['real_name' => 'merchantAudit']); + Route::get('/merchant/group/list', [MerchantController::class, 'groupList']) + ->name('merchantGroupList') + ->setParams(['real_name' => 'merchantGroupList']); + Route::post('/merchant/group/save', [MerchantController::class, 'groupSave']) + ->name('merchantGroupSave') + ->setParams(['real_name' => 'merchantGroupSave']); + Route::post('/merchant/group/delete', [MerchantController::class, 'groupDelete']) + ->name('merchantGroupDelete') + ->setParams(['real_name' => 'merchantGroupDelete']); + Route::get('/merchant/package/list', [MerchantController::class, 'packageList']) + ->name('merchantPackageList') + ->setParams(['real_name' => 'merchantPackageList']); + Route::post('/merchant/package/save', [MerchantController::class, 'packageSave']) + ->name('merchantPackageSave') + ->setParams(['real_name' => 'merchantPackageSave']); + Route::post('/merchant/package/delete', [MerchantController::class, 'packageDelete']) + ->name('merchantPackageDelete') + ->setParams(['real_name' => 'merchantPackageDelete']); + Route::post('/merchant/save', [MerchantController::class, 'save']) + ->name('merchantSave') + ->setParams(['real_name' => 'merchantSave']); + Route::post('/merchant/profile/save', [MerchantController::class, 'profileSave']) + ->name('merchantProfileSave') + ->setParams(['real_name' => 'merchantProfileSave']); + Route::post('/merchant/audit-action', [MerchantController::class, 'auditAction']) + ->name('merchantAuditAction') + ->setParams(['real_name' => 'merchantAuditAction']); + Route::post('/merchant/toggle', [MerchantController::class, 'toggle']) + ->name('merchantToggle') + ->setParams(['real_name' => 'merchantToggle']); + + Route::get('/merchant-app/list', [MerchantAppController::class, 'list']) + ->name('merchantAppList') + ->setParams(['real_name' => 'merchantAppList']); + Route::get('/merchant-app/detail', [MerchantAppController::class, 'detail']) + ->name('merchantAppDetail') + ->setParams(['real_name' => 'merchantAppDetail']); + Route::get('/merchant-app/config/detail', [MerchantAppController::class, 'configDetail']) + ->name('merchantAppConfigDetail') + ->setParams(['real_name' => 'merchantAppConfigDetail']); + Route::post('/merchant-app/save', [MerchantAppController::class, 'save']) + ->name('merchantAppSave') + ->setParams(['real_name' => 'merchantAppSave']); + Route::post('/merchant-app/config/save', [MerchantAppController::class, 'configSave']) + ->name('merchantAppConfigSave') + ->setParams(['real_name' => 'merchantAppConfigSave']); + Route::post('/merchant-app/reset-secret', [MerchantAppController::class, 'resetSecret']) + ->name('merchantAppResetSecret') + ->setParams(['real_name' => 'merchantAppResetSecret']); + Route::post('/merchant-app/toggle', [MerchantAppController::class, 'toggle']) + ->name('merchantAppToggle') + ->setParams(['real_name' => 'merchantAppToggle']); + + Route::get('/pay-method/list', [PayMethodController::class, 'list']) + ->name('payMethodList') + ->setParams(['real_name' => 'payMethodList']); + Route::post('/pay-method/save', [PayMethodController::class, 'save']) + ->name('payMethodSave') + ->setParams(['real_name' => 'payMethodSave']); + Route::post('/pay-method/toggle', [PayMethodController::class, 'toggle']) + ->name('payMethodToggle') + ->setParams(['real_name' => 'payMethodToggle']); + + Route::get('/pay-plugin/list', [PayPluginController::class, 'list']) + ->name('payPluginList') + ->setParams(['real_name' => 'payPluginList']); + Route::post('/pay-plugin/save', [PayPluginController::class, 'save']) + ->name('payPluginSave') + ->setParams(['real_name' => 'payPluginSave']); + Route::post('/pay-plugin/toggle', [PayPluginController::class, 'toggle']) + ->name('payPluginToggle') + ->setParams(['real_name' => 'payPluginToggle']); + + Route::get('/order/list', [OrderController::class, 'list']) + ->name('orderList') + ->setParams(['real_name' => 'orderList']); + Route::get('/order/export', [OrderController::class, 'export']) + ->name('orderExport') + ->setParams(['real_name' => 'orderExport']); + Route::get('/order/detail', [OrderController::class, 'detail']) + ->name('orderDetail') + ->setParams(['real_name' => 'orderDetail']); + Route::post('/order/refund', [OrderController::class, 'refund']) + ->name('orderRefund') + ->setParams(['real_name' => 'orderRefund']); })->middleware([AuthMiddleware::class]); -})->middleware([Cors::class]); \ No newline at end of file +})->middleware([Cors::class]); diff --git a/app/routes/api.php b/app/routes/api.php index 1f1eabc..9dca864 100644 --- a/app/routes/api.php +++ b/app/routes/api.php @@ -1,11 +1,12 @@ configService->getValue(self::CONFIG_KEY, '[]'); + + if (is_array($raw)) { + $policies = $raw; + } else { + $decoded = json_decode((string)$raw, true); + $policies = is_array($decoded) ? $decoded : []; + } + + usort($policies, function (array $left, array $right) { + return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? '')); + }); + + return $policies; + } + + public function save(array $policyData): array + { + $policies = $this->list(); + $id = trim((string)($policyData['id'] ?? '')); + $now = date('Y-m-d H:i:s'); + + $stored = [ + 'id' => $id !== '' ? $id : $this->generateId(), + 'policy_name' => trim((string)($policyData['policy_name'] ?? '')), + 'merchant_id' => (int)($policyData['merchant_id'] ?? 0), + 'merchant_app_id' => (int)($policyData['merchant_app_id'] ?? 0), + 'method_code' => trim((string)($policyData['method_code'] ?? '')), + 'plugin_code' => trim((string)($policyData['plugin_code'] ?? '')), + 'route_mode' => trim((string)($policyData['route_mode'] ?? 'priority')), + 'status' => (int)($policyData['status'] ?? 1), + 'circuit_breaker_threshold' => max(0, min(100, (int)($policyData['circuit_breaker_threshold'] ?? 50))), + 'failover_cooldown' => max(0, (int)($policyData['failover_cooldown'] ?? 10)), + 'remark' => trim((string)($policyData['remark'] ?? '')), + 'items' => array_values($policyData['items'] ?? []), + 'updated_at' => $now, + ]; + + $found = false; + foreach ($policies as &$policy) { + if (($policy['id'] ?? '') !== $stored['id']) { + continue; + } + + $stored['created_at'] = $policy['created_at'] ?? $now; + $policy = $stored; + $found = true; + break; + } + unset($policy); + + if (!$found) { + $stored['created_at'] = $now; + $policies[] = $stored; + } + + $this->configService->setValue(self::CONFIG_KEY, $policies); + + return $stored; + } + + public function delete(string $id): bool + { + $id = trim($id); + if ($id === '') { + return false; + } + + $policies = $this->list(); + $filtered = array_values(array_filter($policies, function (array $policy) use ($id) { + return ($policy['id'] ?? '') !== $id; + })); + + if (count($filtered) === count($policies)) { + return false; + } + + $this->configService->setValue(self::CONFIG_KEY, $filtered); + return true; + } + + private function generateId(): string + { + return 'rp_' . date('YmdHis') . mt_rand(1000, 9999); + } +} diff --git a/app/services/ChannelRouterService.php b/app/services/ChannelRouterService.php index 79abd82..d5bd3fb 100644 --- a/app/services/ChannelRouterService.php +++ b/app/services/ChannelRouterService.php @@ -6,37 +6,608 @@ use app\common\base\BaseService; use app\exceptions\NotFoundException; use app\models\PaymentChannel; use app\repositories\PaymentChannelRepository; +use app\repositories\PaymentMethodRepository; +use app\repositories\PaymentOrderRepository; /** * 通道路由服务 * - * 负责根据商户、应用、支付方式选择合适的通道 + * 负责根据商户、应用、支付方式和已保存策略选择可用通道, + * 同时也为后台提供策略草稿预览能力,保证预览和真实下单尽量一致。 */ class ChannelRouterService extends BaseService { + private const HEALTH_LOOKBACK_DAYS = 7; + public function __construct( - protected PaymentChannelRepository $channelRepository + protected PaymentChannelRepository $channelRepository, + protected PaymentMethodRepository $methodRepository, + protected PaymentOrderRepository $orderRepository, + protected ChannelRoutePolicyService $routePolicyService, ) { } /** - * 选择通道 - * - * @param int $merchantId 商户ID - * @param int $merchantAppId 商户应用ID - * @param int $methodId 支付方式ID - * @return PaymentChannel - * @throws NotFoundException + * 向后兼容:只返回选中的通道模型。 */ - public function chooseChannel(int $merchantId, int $merchantAppId, int $methodId): PaymentChannel + public function chooseChannel(int $merchantId, int $merchantAppId, int $methodId, float $amount = 0): PaymentChannel { - $channel = $this->channelRepository->findAvailableChannel($merchantId, $merchantAppId, $methodId); + $decision = $this->chooseChannelWithDecision($merchantId, $merchantAppId, $methodId, $amount); + return $decision['channel']; + } - if (!$channel) { - throw new NotFoundException("未找到可用的支付通道:商户ID={$merchantId}, 应用ID={$merchantAppId}, 支付方式ID={$methodId}"); + /** + * 返回完整路由决策信息,便于下单链路记录调度痕迹。 + * + * @return array{ + * channel:PaymentChannel, + * source:string, + * route_mode:string, + * policy:?array, + * candidates:array> + * } + */ + public function chooseChannelWithDecision(int $merchantId, int $merchantAppId, int $methodId, float $amount = 0): array + { + $routingContext = $this->loadRoutingContexts($merchantId, $merchantAppId, $methodId, $amount); + $method = $routingContext['method']; + $contexts = $routingContext['contexts']; + + $decision = $this->chooseByPolicy( + $merchantId, + $merchantAppId, + (string)$method->method_code, + $contexts + ); + + if ($decision !== null) { + return $decision; } - return $channel; + $decision = $this->chooseFallback($contexts); + if ($decision !== null) { + return $decision; + } + + throw new NotFoundException( + $this->buildNoChannelMessage($merchantId, $merchantAppId, (string)$method->method_name, $contexts) + ); + } + + /** + * 预览一个尚未保存的策略草稿在当前真实通道环境下会如何命中。 + */ + public function previewPolicyDraft( + int $merchantId, + int $merchantAppId, + int $methodId, + array $policy, + float $amount = 0 + ): array { + $routingContext = $this->loadRoutingContexts($merchantId, $merchantAppId, $methodId, $amount); + $method = $routingContext['method']; + $contexts = $routingContext['contexts']; + $previewPolicy = $this->normalizePreviewPolicy($policy, $merchantId, $merchantAppId, (string)$method->method_code); + $evaluation = $this->evaluatePolicy($previewPolicy, $contexts); + + $selectedChannel = null; + if ($evaluation['selected_candidate'] !== null) { + $selectedContext = $contexts[(int)$evaluation['selected_candidate']['channel_id']] ?? null; + if ($selectedContext !== null) { + /** @var PaymentChannel $channel */ + $channel = $selectedContext['channel']; + $selectedChannel = [ + 'id' => (int)$channel->id, + 'chan_code' => (string)$channel->chan_code, + 'chan_name' => (string)$channel->chan_name, + ]; + } + } + + return [ + 'matched' => $selectedChannel !== null, + 'source' => 'preview', + 'route_mode' => (string)($previewPolicy['route_mode'] ?? 'priority'), + 'policy' => $this->buildPolicyMeta($previewPolicy), + 'selected_channel' => $selectedChannel, + 'candidates' => $evaluation['candidates'], + 'summary' => [ + 'candidate_count' => count($evaluation['candidates']), + 'available_count' => count($evaluation['available_candidates']), + 'blocked_count' => count($evaluation['candidates']) - count($evaluation['available_candidates']), + ], + 'message' => $selectedChannel !== null ? '本次模拟已命中策略通道' : '当前策略下没有可用通道', + ]; + } + + private function chooseByPolicy(int $merchantId, int $merchantAppId, string $methodCode, array $contexts): ?array + { + return $this->chooseByPolicies( + $merchantId, + $merchantAppId, + $methodCode, + $contexts, + $this->routePolicyService->list() + ); + } + + private function chooseFallback(array $contexts): ?array + { + $candidates = []; + foreach ($contexts as $context) { + /** @var PaymentChannel $channel */ + $channel = $context['channel']; + $candidates[] = [ + 'channel_id' => (int)$channel->id, + 'chan_code' => (string)$channel->chan_code, + 'chan_name' => (string)$channel->chan_name, + 'available' => $context['available'], + 'reasons' => $context['reasons'], + 'priority' => (int)$channel->sort, + 'weight' => 100, + 'role' => 'normal', + 'health_score' => $context['health_score'], + 'success_rate' => $context['success_rate'], + ]; + } + + $availableCandidates = array_values(array_filter($candidates, fn(array $item) => (bool)$item['available'])); + if ($availableCandidates === []) { + return null; + } + + usort($availableCandidates, function (array $left, array $right) { + if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) { + if (($right['health_score'] ?? 0) === ($left['health_score'] ?? 0)) { + return ($right['success_rate'] ?? 0) <=> ($left['success_rate'] ?? 0); + } + return ($right['health_score'] ?? 0) <=> ($left['health_score'] ?? 0); + } + return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0); + }); + + $selectedCandidate = $availableCandidates[0]; + $selectedContext = $contexts[(int)$selectedCandidate['channel_id']] ?? null; + if (!$selectedContext) { + return null; + } + + return [ + 'channel' => $selectedContext['channel'], + 'source' => 'fallback', + 'route_mode' => 'sort', + 'policy' => null, + 'candidates' => $candidates, + ]; + } + + private function loadRoutingContexts(int $merchantId, int $merchantAppId, int $methodId, float $amount): array + { + $method = $this->methodRepository->find($methodId); + if (!$method) { + throw new NotFoundException("未找到支付方式:{$methodId}"); + } + + $channels = $this->channelRepository->searchList([ + 'merchant_id' => $merchantId, + 'merchant_app_id' => $merchantAppId, + 'method_id' => $methodId, + ]); + + if ($channels->isEmpty()) { + throw new NotFoundException( + "未找到可用的支付通道:商户ID={$merchantId}, 应用ID={$merchantAppId}, 支付方式ID={$methodId}" + ); + } + + $todayRange = $this->getDateRange(1); + $recentRange = $this->getDateRange(self::HEALTH_LOOKBACK_DAYS); + $channelIds = []; + foreach ($channels as $channel) { + $channelIds[] = (int)$channel->id; + } + + $todayStatsMap = $this->orderRepository->aggregateByChannel($channelIds, [ + 'merchant_id' => $merchantId, + 'merchant_app_id' => $merchantAppId, + 'method_id' => $methodId, + 'created_from' => $todayRange['created_from'], + 'created_to' => $todayRange['created_to'], + ]); + $recentStatsMap = $this->orderRepository->aggregateByChannel($channelIds, [ + 'merchant_id' => $merchantId, + 'merchant_app_id' => $merchantAppId, + 'method_id' => $methodId, + 'created_from' => $recentRange['created_from'], + 'created_to' => $recentRange['created_to'], + ]); + + $contexts = []; + foreach ($channels as $channel) { + $contexts[(int)$channel->id] = $this->buildChannelContext( + $channel, + $todayStatsMap[(int)$channel->id] ?? [], + $recentStatsMap[(int)$channel->id] ?? [], + $amount + ); + } + + return [ + 'method' => $method, + 'contexts' => $contexts, + ]; + } + + private function buildChannelContext(PaymentChannel $channel, array $todayStats, array $recentStats, float $amount): array + { + $reasons = []; + $status = (int)$channel->status; + + $todayOrders = (int)($todayStats['total_orders'] ?? 0); + $todaySuccessAmount = round((float)($todayStats['success_amount'] ?? 0), 2); + $recentTotalOrders = (int)($recentStats['total_orders'] ?? 0); + $recentSuccessOrders = (int)($recentStats['success_orders'] ?? 0); + $recentPendingOrders = (int)($recentStats['pending_orders'] ?? 0); + $recentFailOrders = (int)($recentStats['fail_orders'] ?? 0); + + $dailyLimit = (float)$channel->daily_limit; + $dailyCount = (int)$channel->daily_cnt; + $minAmount = $channel->min_amount === null ? null : (float)$channel->min_amount; + $maxAmount = $channel->max_amount === null ? null : (float)$channel->max_amount; + + if ($status !== 1) { + $reasons[] = '通道已禁用'; + } + if ($amount > 0 && $minAmount !== null && $amount < $minAmount) { + $reasons[] = '低于最小支付金额'; + } + if ($amount > 0 && $maxAmount !== null && $maxAmount > 0 && $amount > $maxAmount) { + $reasons[] = '超过最大支付金额'; + } + if ($dailyLimit > 0 && $todaySuccessAmount + max(0, $amount) > $dailyLimit) { + $reasons[] = '超出单日限额'; + } + if ($dailyCount > 0 && $todayOrders + 1 > $dailyCount) { + $reasons[] = '超出单日笔数限制'; + } + + $successRate = $recentTotalOrders > 0 ? round($recentSuccessOrders / $recentTotalOrders * 100, 2) : 0; + $dailyLimitUsageRate = $dailyLimit > 0 ? round(min(100, ($todaySuccessAmount / $dailyLimit) * 100), 2) : null; + $healthScore = $this->calculateHealthScore( + $status, + $recentTotalOrders, + $recentSuccessOrders, + $recentPendingOrders, + $recentFailOrders, + $dailyLimitUsageRate + ); + + return [ + 'channel' => $channel, + 'available' => $reasons === [], + 'reasons' => $reasons, + 'success_rate' => $successRate, + 'health_score' => $healthScore, + 'today_orders' => $todayOrders, + 'today_success_amount' => $todaySuccessAmount, + ]; + } + + private function calculateHealthScore( + int $status, + int $totalOrders, + int $successOrders, + int $pendingOrders, + int $failOrders, + ?float $todayLimitUsageRate + ): int { + if ($status !== 1) { + return 0; + } + + if ($totalOrders === 0) { + return 60; + } + + $successRate = $totalOrders > 0 ? ($successOrders / $totalOrders * 100) : 0; + $healthScore = 90; + + if ($successRate < 95) { + $healthScore -= 10; + } + if ($successRate < 80) { + $healthScore -= 15; + } + if ($successRate < 60) { + $healthScore -= 20; + } + if ($failOrders > 0) { + $healthScore -= min(15, $failOrders * 3); + } + if ($pendingOrders > max(3, (int)floor($successOrders / 2))) { + $healthScore -= 10; + } + if ($todayLimitUsageRate !== null && $todayLimitUsageRate >= 90) { + $healthScore -= 20; + } elseif ($todayLimitUsageRate !== null && $todayLimitUsageRate >= 75) { + $healthScore -= 10; + } + + return max(0, min(100, $healthScore)); + } + + private function chooseByPolicies( + int $merchantId, + int $merchantAppId, + string $methodCode, + array $contexts, + array $policies + ): ?array { + $matchedPolicies = array_values(array_filter($policies, function (array $policy) use ( + $merchantId, + $merchantAppId, + $methodCode + ) { + if ((int)($policy['status'] ?? 0) !== 1) { + return false; + } + if (($policy['method_code'] ?? '') !== $methodCode) { + return false; + } + + $policyMerchantId = (int)($policy['merchant_id'] ?? 0); + if ($policyMerchantId > 0 && $policyMerchantId !== $merchantId) { + return false; + } + + $policyAppId = (int)($policy['merchant_app_id'] ?? 0); + if ($policyAppId > 0 && $policyAppId !== $merchantAppId) { + return false; + } + + return is_array($policy['items'] ?? null) && $policy['items'] !== []; + })); + + if ($matchedPolicies === []) { + return null; + } + + usort($matchedPolicies, function (array $left, array $right) { + $leftScore = $this->calculatePolicySpecificity($left); + $rightScore = $this->calculatePolicySpecificity($right); + if ($leftScore === $rightScore) { + return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? '')); + } + return $rightScore <=> $leftScore; + }); + + foreach ($matchedPolicies as $policy) { + $evaluation = $this->evaluatePolicy($policy, $contexts); + if ($evaluation['selected_candidate'] === null) { + continue; + } + + $selectedContext = $contexts[(int)$evaluation['selected_candidate']['channel_id']] ?? null; + if (!$selectedContext) { + continue; + } + + return [ + 'channel' => $selectedContext['channel'], + 'source' => 'policy', + 'route_mode' => (string)($policy['route_mode'] ?? 'priority'), + 'policy' => $this->buildPolicyMeta($policy), + 'candidates' => $evaluation['candidates'], + ]; + } + + return null; + } + + private function normalizePolicyItems(array $items): array + { + $normalized = []; + foreach ($items as $index => $item) { + $normalized[] = [ + 'channel_id' => (int)($item['channel_id'] ?? 0), + 'role' => (string)($item['role'] ?? ($index === 0 ? 'primary' : 'backup')), + 'weight' => max(0, (int)($item['weight'] ?? 100)), + 'priority' => max(1, (int)($item['priority'] ?? ($index + 1))), + ]; + } + + usort($normalized, function (array $left, array $right) { + if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) { + if (($left['role'] ?? '') === ($right['role'] ?? '')) { + return ($right['weight'] ?? 0) <=> ($left['weight'] ?? 0); + } + + return ($left['role'] ?? '') === 'primary' ? -1 : 1; + } + + return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0); + }); + + return $normalized; + } + + private function evaluatePolicy(array $policy, array $contexts): array + { + $items = $this->normalizePolicyItems($policy['items'] ?? []); + $candidates = []; + + foreach ($items as $item) { + $channelId = (int)($item['channel_id'] ?? 0); + $context = $contexts[$channelId] ?? null; + if (!$context) { + $candidates[] = [ + 'channel_id' => $channelId, + 'chan_code' => '', + 'chan_name' => '', + 'available' => false, + 'reasons' => ['通道不存在或不属于当前应用'], + 'priority' => (int)($item['priority'] ?? 1), + 'weight' => (int)($item['weight'] ?? 100), + 'role' => (string)($item['role'] ?? 'backup'), + 'health_score' => 0, + 'success_rate' => 0, + ]; + continue; + } + + /** @var PaymentChannel $channel */ + $channel = $context['channel']; + $pluginCode = trim((string)($policy['plugin_code'] ?? '')); + $policyReasons = []; + if ($pluginCode !== '' && (string)$channel->plugin_code !== $pluginCode) { + $policyReasons[] = '插件与策略限定不匹配'; + } + + $available = $context['available'] && $policyReasons === []; + $candidates[] = [ + 'channel_id' => (int)$channel->id, + 'chan_code' => (string)$channel->chan_code, + 'chan_name' => (string)$channel->chan_name, + 'available' => $available, + 'reasons' => $available ? [] : array_values(array_unique(array_merge($context['reasons'], $policyReasons))), + 'priority' => (int)($item['priority'] ?? 1), + 'weight' => (int)($item['weight'] ?? 100), + 'role' => (string)($item['role'] ?? 'backup'), + 'health_score' => $context['health_score'], + 'success_rate' => $context['success_rate'], + ]; + } + + $availableCandidates = array_values(array_filter($candidates, fn(array $item) => (bool)$item['available'])); + $selectedCandidate = $availableCandidates === [] + ? null + : $this->pickCandidateByMode($availableCandidates, (string)($policy['route_mode'] ?? 'priority')); + + return [ + 'candidates' => $candidates, + 'available_candidates' => $availableCandidates, + 'selected_candidate' => $selectedCandidate, + ]; + } + + private function pickCandidateByMode(array $candidates, string $routeMode): array + { + usort($candidates, function (array $left, array $right) { + if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) { + if (($left['role'] ?? '') === ($right['role'] ?? '')) { + return ($right['weight'] ?? 0) <=> ($left['weight'] ?? 0); + } + + return ($left['role'] ?? '') === 'primary' ? -1 : 1; + } + + return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0); + }); + + if ($routeMode !== 'weight') { + return $candidates[0]; + } + + $totalWeight = 0; + foreach ($candidates as $candidate) { + $totalWeight += max(0, (int)($candidate['weight'] ?? 0)); + } + + if ($totalWeight <= 0) { + return $candidates[0]; + } + + $cursor = mt_rand(1, $totalWeight); + foreach ($candidates as $candidate) { + $cursor -= max(0, (int)($candidate['weight'] ?? 0)); + if ($cursor <= 0) { + return $candidate; + } + } + + return $candidates[0]; + } + + private function calculatePolicySpecificity(array $policy): int + { + $score = 0; + if ((int)($policy['merchant_id'] ?? 0) > 0) { + $score += 10; + } + if ((int)($policy['merchant_app_id'] ?? 0) > 0) { + $score += 20; + } + if (trim((string)($policy['plugin_code'] ?? '')) !== '') { + $score += 5; + } + + return $score; + } + + private function buildPolicyMeta(array $policy): array + { + return [ + 'id' => (string)($policy['id'] ?? ''), + 'policy_name' => (string)($policy['policy_name'] ?? ''), + 'plugin_code' => (string)($policy['plugin_code'] ?? ''), + 'circuit_breaker_threshold' => (int)($policy['circuit_breaker_threshold'] ?? 0), + 'failover_cooldown' => (int)($policy['failover_cooldown'] ?? 0), + ]; + } + + private function buildNoChannelMessage(int $merchantId, int $merchantAppId, string $methodName, array $contexts): string + { + $messages = []; + foreach ($contexts as $context) { + /** @var PaymentChannel $channel */ + $channel = $context['channel']; + $reasonText = $context['reasons'] === [] ? '无可用原因记录' : implode('、', $context['reasons']); + $messages[] = sprintf('%s(%s):%s', (string)$channel->chan_name, (string)$channel->chan_code, $reasonText); + } + + usort($messages, fn(string $left, string $right) => strcmp($left, $right)); + $messages = array_slice($messages, 0, 3); + + $suffix = $messages === [] ? '' : ',原因:' . implode(';', $messages); + + return sprintf( + '未找到可用的支付通道:商户ID=%d,应用ID=%d,支付方式=%s%s', + $merchantId, + $merchantAppId, + $methodName, + $suffix + ); + } + + private function getDateRange(int $days): array + { + $days = max(1, $days); + return [ + 'created_from' => date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')), + 'created_to' => date('Y-m-d H:i:s'), + ]; + } + + private function normalizePreviewPolicy(array $policy, int $merchantId, int $merchantAppId, string $methodCode): array + { + $routeMode = trim((string)($policy['route_mode'] ?? 'priority')); + if (!in_array($routeMode, ['priority', 'weight', 'failover'], true)) { + $routeMode = 'priority'; + } + + return [ + 'id' => trim((string)($policy['id'] ?? 'preview_policy')), + 'policy_name' => trim((string)($policy['policy_name'] ?? '策略草稿')), + 'merchant_id' => $merchantId, + 'merchant_app_id' => $merchantAppId, + 'method_code' => $methodCode, + 'plugin_code' => trim((string)($policy['plugin_code'] ?? '')), + 'route_mode' => $routeMode, + 'status' => 1, + 'circuit_breaker_threshold' => max(0, min(100, (int)($policy['circuit_breaker_threshold'] ?? 50))), + 'failover_cooldown' => max(0, (int)($policy['failover_cooldown'] ?? 10)), + 'items' => $this->normalizePolicyItems($policy['items'] ?? []), + 'updated_at' => date('Y-m-d H:i:s'), + ]; } } - diff --git a/app/services/PayNotifyService.php b/app/services/PayNotifyService.php new file mode 100644 index 0000000..2582d88 --- /dev/null +++ b/app/services/PayNotifyService.php @@ -0,0 +1,199 @@ + 幂等 -> 更新订单 -> 创建商户通知任务。 + */ +class PayNotifyService extends BaseService +{ + public function __construct( + protected PluginService $pluginService, + protected PaymentStateService $paymentStateService, + protected CallbackInboxRepository $callbackInboxRepository, + protected PaymentChannelRepository $channelRepository, + protected PaymentCallbackLogRepository $callbackLogRepository, + protected PaymentOrderRepository $orderRepository, + protected NotifyService $notifyService, + ) { + } + + /** + * @return array{ok:bool,already?:bool,msg:string,order_id?:string} + */ + public function handleNotify(string $pluginCode, Request $request): array + { + $rawPayload = array_merge($request->get(), $request->post()); + $candidateOrderId = $this->extractOrderIdFromPayload($rawPayload); + $order = $candidateOrderId !== '' ? $this->orderRepository->findByOrderId($candidateOrderId) : null; + + try { + $plugin = $this->pluginService->getPluginInstance($pluginCode); + + // 验签前初始化插件配置,保证如支付宝证书验签等能力可用。 + if ($order && (int)$order->channel_id > 0) { + $channel = $this->channelRepository->find((int)$order->channel_id); + if ($channel) { + if ((string)$channel->plugin_code !== $pluginCode) { + return ['ok' => false, 'msg' => 'plugin mismatch']; + } + $channelConfig = array_merge( + $channel->getConfigArray(), + ['enabled_products' => $channel->getEnabledProducts()] + ); + $plugin->init($channelConfig); + } + } + + $notifyData = $plugin->notify($request); + } catch (\Throwable $e) { + $this->callbackLogRepository->createLog([ + 'order_id' => $candidateOrderId, + 'channel_id' => $order ? (int)$order->channel_id : 0, + 'callback_type' => 'notify', + 'request_data' => json_encode($rawPayload, JSON_UNESCAPED_UNICODE), + 'verify_status' => 0, + 'process_status' => 0, + 'process_result' => $e->getMessage(), + ]); + return ['ok' => false, 'msg' => 'verify failed']; + } + + $orderId = (string)($notifyData['pay_order_id'] ?? ''); + $status = strtolower((string)($notifyData['status'] ?? '')); + $chanTradeNo = (string)($notifyData['chan_trade_no'] ?? ''); + + if ($orderId === '') { + return ['ok' => false, 'msg' => 'missing pay_order_id']; + } + + // 已验签但状态非 success 时,也走状态机进行失败态收敛。 + if ($status !== 'success') { + $order = $this->orderRepository->findByOrderId($orderId); + if ($order) { + try { + $this->paymentStateService->markFailed($order); + } catch (\Throwable $e) { + // 非法迁移不影响回调日志记录 + } + } + + $this->callbackLogRepository->createLog([ + 'order_id' => $orderId, + 'channel_id' => $order ? (int)$order->channel_id : 0, + 'callback_type' => 'notify', + 'request_data' => json_encode($rawPayload, JSON_UNESCAPED_UNICODE), + 'verify_status' => 1, + 'process_status' => 0, + 'process_result' => 'notify status is not success', + ]); + + return ['ok' => false, 'msg' => 'notify status is not success']; + } + + $eventKey = $this->buildEventKey($pluginCode, $orderId, $chanTradeNo, $notifyData); + $payload = $rawPayload; + + $inserted = $this->callbackInboxRepository->createIfAbsent([ + 'event_key' => $eventKey, + 'plugin_code' => $pluginCode, + 'order_id' => $orderId, + 'chan_trade_no' => $chanTradeNo, + 'payload' => $payload, + 'process_status' => 0, + 'processed_at' => null, + ]); + + if (!$inserted) { + return ['ok' => true, 'already' => true, 'msg' => 'success', 'order_id' => $orderId]; + } + + $order = $this->orderRepository->findByOrderId($orderId); + if (!$order) { + $this->callbackLogRepository->createLog([ + 'order_id' => $orderId, + 'channel_id' => 0, + 'callback_type' => 'notify', + 'request_data' => json_encode($payload, JSON_UNESCAPED_UNICODE), + 'verify_status' => 1, + 'process_status' => 0, + 'process_result' => 'order not found', + ]); + + return ['ok' => false, 'msg' => 'order not found']; + } + + try { + $this->transaction(function () use ($order, $chanTradeNo, $payload, $pluginCode) { + $this->paymentStateService->markPaid($order, $chanTradeNo); + + $this->callbackLogRepository->createLog([ + 'order_id' => $order->order_id, + 'channel_id' => (int)$order->channel_id, + 'callback_type' => 'notify', + 'request_data' => json_encode($payload, JSON_UNESCAPED_UNICODE), + 'verify_status' => 1, + 'process_status' => 1, + 'process_result' => 'success:' . $pluginCode, + ]); + + $this->notifyService->createNotifyTask($order->order_id); + }); + } catch (\Throwable $e) { + $this->callbackLogRepository->createLog([ + 'order_id' => $order->order_id, + 'channel_id' => (int)$order->channel_id, + 'callback_type' => 'notify', + 'request_data' => json_encode($payload, JSON_UNESCAPED_UNICODE), + 'verify_status' => 1, + 'process_status' => 0, + 'process_result' => $e->getMessage(), + ]); + return ['ok' => false, 'msg' => 'process failed']; + } + + $event = $this->callbackInboxRepository->findByEventKey($eventKey); + if ($event) { + $this->callbackInboxRepository->updateById((int)$event->id, [ + 'process_status' => 1, + 'processed_at' => date('Y-m-d H:i:s'), + ]); + } + + return ['ok' => true, 'msg' => 'success', 'order_id' => $orderId]; + } + + private function buildEventKey(string $pluginCode, string $orderId, string $chanTradeNo, array $notifyData): string + { + $base = $pluginCode . '|' . $orderId . '|' . $chanTradeNo . '|' . ($notifyData['status'] ?? ''); + return sha1($base); + } + + private function extractOrderIdFromPayload(array $payload): string + { + $candidates = [ + $payload['pay_order_id'] ?? null, + $payload['order_id'] ?? null, + $payload['out_trade_no'] ?? null, + $payload['trade_no'] ?? null, + ]; + + foreach ($candidates as $id) { + $value = trim((string)$id); + if ($value !== '') { + return $value; + } + } + + return ''; + } +} diff --git a/app/services/PayOrderService.php b/app/services/PayOrderService.php index 8731dbb..b793f1c 100644 --- a/app/services/PayOrderService.php +++ b/app/services/PayOrderService.php @@ -21,6 +21,7 @@ class PayOrderService extends BaseService protected PaymentOrderRepository $orderRepository, protected PaymentMethodRepository $methodRepository, protected PluginService $pluginService, + protected PaymentStateService $paymentStateService, ) {} /** @@ -168,12 +169,7 @@ class PayOrderService extends BaseService // 8. 如果是全额退款则关闭订单 if ($refundAmount >= $order->amount) { - $this->orderRepository->updateById($order->id, [ - 'status' => PaymentOrder::STATUS_CLOSED, - 'extra' => array_merge($order->extra ?? [], [ - 'refund_info' => $refundResult, - ]), - ]); + $this->paymentStateService->closeAfterFullRefund($order, $refundResult); } return [ diff --git a/app/services/PayService.php b/app/services/PayService.php index b67fb94..f1933e9 100644 --- a/app/services/PayService.php +++ b/app/services/PayService.php @@ -42,6 +42,7 @@ class PayService extends BaseService // 1. 创建订单(幂等) /** @var PaymentOrder $order */ $order = $this->payOrderService->createOrder($orderData); + $extra = $order->extra ?? []; // 2. 查询支付方式 $method = $this->methodRepository->find($order->method_id); @@ -50,11 +51,30 @@ class PayService extends BaseService } // 3. 通道路由 - $channel = $this->channelRouterService->chooseChannel( - $order->merchant_id, - $order->merchant_app_id, - $order->method_id - ); + try { + $routeDecision = $this->channelRouterService->chooseChannelWithDecision( + (int)$order->merchant_id, + (int)$order->merchant_app_id, + (int)$order->method_id, + (float)$order->amount + ); + } catch (\Throwable $e) { + $extra['route_error'] = [ + 'message' => $e->getMessage(), + 'at' => date('Y-m-d H:i:s'), + ]; + $this->orderRepository->updateById((int)$order->id, ['extra' => $extra]); + throw $e; + } + + /** @var \app\models\PaymentChannel $channel */ + $channel = $routeDecision['channel']; + unset($extra['route_error']); + $extra['routing'] = $this->buildRoutingSnapshot($routeDecision, $channel); + $this->orderRepository->updateById((int)$order->id, [ + 'channel_id' => (int)$channel->id, + 'extra' => $extra, + ]); // 4. 实例化插件并初始化(通过插件服务) $plugin = $this->pluginService->getPluginInstance($channel->plugin_code); @@ -85,7 +105,7 @@ class PayService extends BaseService 'amount' => $order->amount, 'subject' => $order->subject, 'body' => $order->body, - 'extra' => $order->extra ?? [], + 'extra' => $extra, '_env' => $env, ]; @@ -98,7 +118,6 @@ class PayService extends BaseService $realAmount = round($amount - $fee, 2); // 8. 更新订单(通道、支付参数、实际金额) - $extra = $order->extra ?? []; $extra['pay_params'] = $payResult['pay_params'] ?? null; $chanOrderNo = $payResult['chan_order_no'] ?? $payResult['channel_order_no'] ?? ''; $chanTradeNo = $payResult['chan_trade_no'] ?? $payResult['channel_trade_no'] ?? ''; @@ -119,6 +138,35 @@ class PayService extends BaseService ]; } + private function buildRoutingSnapshot(array $routeDecision, \app\models\PaymentChannel $channel): array + { + $policy = is_array($routeDecision['policy'] ?? null) ? $routeDecision['policy'] : null; + $candidates = []; + foreach (($routeDecision['candidates'] ?? []) as $candidate) { + $candidates[] = [ + 'channel_id' => (int)($candidate['channel_id'] ?? 0), + 'chan_code' => (string)($candidate['chan_code'] ?? ''), + 'chan_name' => (string)($candidate['chan_name'] ?? ''), + 'available' => (bool)($candidate['available'] ?? false), + 'priority' => (int)($candidate['priority'] ?? 0), + 'weight' => (int)($candidate['weight'] ?? 0), + 'role' => (string)($candidate['role'] ?? ''), + 'reasons' => array_values($candidate['reasons'] ?? []), + ]; + } + + return [ + 'source' => (string)($routeDecision['source'] ?? 'fallback'), + 'route_mode' => (string)($routeDecision['route_mode'] ?? 'sort'), + 'policy' => $policy, + 'selected_channel_id' => (int)$channel->id, + 'selected_channel_code' => (string)$channel->chan_code, + 'selected_channel_name' => (string)$channel->chan_name, + 'candidates' => array_slice($candidates, 0, 10), + 'selected_at' => date('Y-m-d H:i:s'), + ]; + } + /** * 根据请求 UA 检测环境 */ diff --git a/app/services/PaymentStateService.php b/app/services/PaymentStateService.php new file mode 100644 index 0000000..858ddbb --- /dev/null +++ b/app/services/PaymentStateService.php @@ -0,0 +1,113 @@ + SUCCESS/FAIL/CLOSED + * - SUCCESS -> CLOSED + */ +class PaymentStateService extends BaseService +{ + public function __construct( + protected PaymentOrderRepository $orderRepository + ) { + } + + /** + * 回调支付成功。 + * + * @return bool true=状态有变更, false=幂等无变更 + */ + public function markPaid(PaymentOrder $order, string $chanTradeNo = '', ?string $payAt = null): bool + { + $from = (int)$order->status; + if ($from === PaymentOrder::STATUS_SUCCESS) { + return false; + } + if (!$this->canTransit($from, PaymentOrder::STATUS_SUCCESS)) { + throw new BadRequestException("illegal status transition: {$from} -> " . PaymentOrder::STATUS_SUCCESS); + } + + $ok = $this->orderRepository->updateById((int)$order->id, [ + 'status' => PaymentOrder::STATUS_SUCCESS, + 'pay_at' => $payAt ?: date('Y-m-d H:i:s'), + 'chan_trade_no' => $chanTradeNo !== '' ? $chanTradeNo : (string)$order->chan_trade_no, + ]); + + return (bool)$ok; + } + + /** + * 标记支付失败(用于已验签的失败回调)。 + * + * @return bool true=状态有变更, false=幂等无变更 + */ + public function markFailed(PaymentOrder $order): bool + { + $from = (int)$order->status; + if ($from === PaymentOrder::STATUS_FAIL) { + return false; + } + if (!$this->canTransit($from, PaymentOrder::STATUS_FAIL)) { + throw new BadRequestException("illegal status transition: {$from} -> " . PaymentOrder::STATUS_FAIL); + } + + $ok = $this->orderRepository->updateById((int)$order->id, [ + 'status' => PaymentOrder::STATUS_FAIL, + ]); + + return (bool)$ok; + } + + /** + * 全额退款后关单。 + * + * @return bool true=状态有变更, false=幂等无变更 + */ + public function closeAfterFullRefund(PaymentOrder $order, array $refundInfo = []): bool + { + $from = (int)$order->status; + if ($from === PaymentOrder::STATUS_CLOSED) { + return false; + } + if (!$this->canTransit($from, PaymentOrder::STATUS_CLOSED)) { + throw new BadRequestException("illegal status transition: {$from} -> " . PaymentOrder::STATUS_CLOSED); + } + + $extra = $order->extra ?? []; + $extra['refund_info'] = $refundInfo; + + $ok = $this->orderRepository->updateById((int)$order->id, [ + 'status' => PaymentOrder::STATUS_CLOSED, + 'extra' => $extra, + ]); + + return (bool)$ok; + } + + private function canTransit(int $from, int $to): bool + { + $allowed = [ + PaymentOrder::STATUS_PENDING => [ + PaymentOrder::STATUS_SUCCESS, + PaymentOrder::STATUS_FAIL, + PaymentOrder::STATUS_CLOSED, + ], + PaymentOrder::STATUS_SUCCESS => [ + PaymentOrder::STATUS_CLOSED, + ], + PaymentOrder::STATUS_FAIL => [], + PaymentOrder::STATUS_CLOSED => [], + ]; + + return in_array($to, $allowed[$from] ?? [], true); + } +} diff --git a/app/services/api/EpayProtocolService.php b/app/services/api/EpayProtocolService.php new file mode 100644 index 0000000..522ced8 --- /dev/null +++ b/app/services/api/EpayProtocolService.php @@ -0,0 +1,106 @@ +method()) { + 'GET' => $request->get(), + 'POST' => $request->post(), + default => $request->all(), + }; + + $params = EpayValidator::make($data) + ->withScene('submit') + ->validate(); + + $result = $this->epayService->submit($params, $request); + $payParams = $result['pay_params'] ?? []; + + if (($payParams['type'] ?? '') === 'redirect' && !empty($payParams['url'])) { + return [ + 'response_type' => 'redirect', + 'url' => $payParams['url'], + ]; + } + + if (($payParams['type'] ?? '') === 'form') { + if (!empty($payParams['html'])) { + return [ + 'response_type' => 'form_html', + 'html' => $payParams['html'], + ]; + } + + return [ + 'response_type' => 'form_params', + 'form' => $payParams, + ]; + } + + return [ + 'response_type' => 'error', + ]; + } + + /** + * 处理 mapi.php 请求 + */ + public function handleMapi(Request $request): array + { + $params = EpayValidator::make($request->post()) + ->withScene('mapi') + ->validate(); + + return $this->epayService->mapi($params, $request); + } + + /** + * 处理 api.php 请求 + */ + public function handleApi(Request $request): array + { + $data = array_merge($request->get(), $request->post()); + $act = strtolower((string)($data['act'] ?? '')); + + if ($act === 'order') { + $params = EpayValidator::make($data) + ->withScene('api_order') + ->validate(); + return $this->epayService->api($params); + } + + if ($act === 'refund') { + $params = EpayValidator::make($data) + ->withScene('api_refund') + ->validate(); + return $this->epayService->api($params); + } + + return [ + 'code' => 0, + 'msg' => '不支持的操作类型', + ]; + } +} + diff --git a/app/services/api/EpayService.php b/app/services/api/EpayService.php index 79c4aa1..3130dfc 100644 --- a/app/services/api/EpayService.php +++ b/app/services/api/EpayService.php @@ -199,7 +199,7 @@ class EpayService extends BaseService ]); return [ - 'code' => 0, + 'code' => 1, 'msg' => '退款成功', ]; } diff --git a/config/base-config/basic.json b/config/base-config/basic.json index a646743..5adb517 100644 --- a/config/base-config/basic.json +++ b/config/base-config/basic.json @@ -1,7 +1,7 @@ -{ +{ "formId": "basic-config", - "title": "基础设置", - "submitText": "保存设置", + "title": "基础配置", + "submitText": "保存配置", "submitUrl": "/adminapi/system/base-config/submit/basic", "cacheKey": "basic_config_cache", "refreshAfterSubmit": true, @@ -37,10 +37,10 @@ { "type": "input", "field": "site_logo", - "title": "站点Logo", + "title": "站点 Logo", "value": "", "props": { - "placeholder": "请输入Logo地址或上传Logo" + "placeholder": "请输入 Logo 地址" } }, { @@ -49,7 +49,7 @@ "title": "备案号", "value": "", "props": { - "placeholder": "请输入ICP备案号" + "placeholder": "请输入 ICP 备案号" } }, { @@ -81,4 +81,3 @@ } ] } - diff --git a/config/base-config/email.json b/config/base-config/email.json index 0db2994..ccf3059 100644 --- a/config/base-config/email.json +++ b/config/base-config/email.json @@ -1,7 +1,7 @@ -{ +{ "formId": "email-config", - "title": "邮件设置", - "submitText": "保存设置", + "title": "邮件配置", + "submitText": "保存配置", "submitUrl": "/adminapi/system/base-config/submit/email", "cacheKey": "email_config_cache", "refreshAfterSubmit": true, @@ -9,7 +9,7 @@ { "type": "input", "field": "smtp_host", - "title": "SMTP服务器", + "title": "SMTP 主机", "value": "", "props": { "placeholder": "例如:smtp.qq.com" @@ -17,14 +17,14 @@ "validate": [ { "required": true, - "message": "SMTP服务器不能为空" + "message": "SMTP 主机不能为空" } ] }, { "type": "inputNumber", "field": "smtp_port", - "title": "SMTP端口", + "title": "SMTP 端口", "value": 465, "props": { "min": 1, @@ -34,14 +34,14 @@ "validate": [ { "required": true, - "message": "SMTP端口不能为空" + "message": "SMTP 端口不能为空" } ] }, { "type": "switch", "field": "smtp_ssl", - "title": "启用SSL", + "title": "启用 SSL", "value": true, "props": { "checkedText": "是", @@ -51,46 +51,46 @@ { "type": "input", "field": "smtp_username", - "title": "SMTP用户名", + "title": "SMTP 用户名", "value": "", "props": { - "placeholder": "请输入SMTP用户名" + "placeholder": "请输入 SMTP 用户名" }, "validate": [ { "required": true, - "message": "SMTP用户名不能为空" + "message": "SMTP 用户名不能为空" } ] }, { "type": "input", "field": "smtp_password", - "title": "SMTP密码", + "title": "SMTP 密码", "value": "", "props": { "type": "password", - "placeholder": "请输入SMTP密码或授权码" + "placeholder": "请输入 SMTP 密码或授权码" }, "validate": [ { "required": true, - "message": "SMTP密码不能为空" + "message": "SMTP 密码不能为空" } ] }, { "type": "input", "field": "from_email", - "title": "发件人邮箱", + "title": "发件邮箱", "value": "", "props": { - "placeholder": "请输入发件人邮箱地址" + "placeholder": "请输入发件邮箱地址" }, "validate": [ { "required": true, - "message": "发件人邮箱不能为空" + "message": "发件邮箱不能为空" }, { "type": "email", @@ -101,7 +101,7 @@ { "type": "input", "field": "from_name", - "title": "发件人名称", + "title": "发件名称", "value": "", "props": { "placeholder": "请输入发件人名称" @@ -109,4 +109,3 @@ } ] } - diff --git a/config/base-config/permission.json b/config/base-config/permission.json index ac9f49e..ccfca13 100644 --- a/config/base-config/permission.json +++ b/config/base-config/permission.json @@ -1,7 +1,7 @@ -{ +{ "formId": "permission-config", - "title": "权限设置", - "submitText": "保存设置", + "title": "权限配置", + "submitText": "保存配置", "submitUrl": "/adminapi/system/base-config/submit/permission", "cacheKey": "permission_config_cache", "refreshAfterSubmit": true, @@ -96,4 +96,3 @@ } ] } - diff --git a/config/base-config/tabs.json b/config/base-config/tabs.json index 4144994..89f464a 100644 --- a/config/base-config/tabs.json +++ b/config/base-config/tabs.json @@ -1,27 +1,26 @@ -[ +[ { "key": "basic", - "title": "基础设置", + "title": "基础配置", "icon": "settings", - "description": "配置系统基础信息,包括站点名称、Logo、备案号等", + "description": "配置系统基础信息,包括站点名称、Logo、备案号和分页默认值。", "sort": 1, "disabled": false }, { "key": "email", - "title": "邮件设置", + "title": "邮件配置", "icon": "email", - "description": "配置邮件服务器相关参数,包括SMTP服务器、端口、账号密码等", + "description": "配置 SMTP 主机、端口、账号和发件人信息,用于通知发送与联通检查。", "sort": 2, "disabled": false }, { "key": "permission", - "title": "权限设置", + "title": "权限配置", "icon": "lock", - "description": "配置系统权限相关参数,预留权限控制功能", + "description": "配置后台权限控制、会话超时、密码强度和登录限制等安全参数。", "sort": 3, "disabled": false } ] - diff --git a/database/patch_callback_inbox.sql b/database/patch_callback_inbox.sql new file mode 100644 index 0000000..0ab9bb5 --- /dev/null +++ b/database/patch_callback_inbox.sql @@ -0,0 +1,20 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `ma_callback_inbox` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `event_key` char(40) NOT NULL DEFAULT '' COMMENT '幂等事件键(SHA1)', + `plugin_code` varchar(32) NOT NULL DEFAULT '' COMMENT '插件编码', + `order_id` varchar(32) NOT NULL DEFAULT '' COMMENT '系统订单号', + `chan_trade_no` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道交易号', + `payload` json DEFAULT NULL COMMENT '回调原始数据', + `process_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '处理状态:0-待处理 1-已处理', + `processed_at` datetime DEFAULT NULL COMMENT '处理完成时间', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_event_key` (`event_key`), + KEY `idx_order_id` (`order_id`), + KEY `idx_plugin_code` (`plugin_code`), + KEY `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付回调幂等收件箱'; + diff --git a/doc/auth_strategy_design.md b/doc/auth_strategy_design.md deleted file mode 100644 index 596cce2..0000000 --- a/doc/auth_strategy_design.md +++ /dev/null @@ -1,145 +0,0 @@ -# 认证策略设计说明 - -## 设计理念 - -采用**策略模式**替代中间件方式处理认证,具有以下优势: - -1. **灵活扩展**:可以轻松添加新的接口标准(如易支付、OpenAPI、自定义标准等) -2. **按需使用**:控制器可以根据需要选择认证策略,而不是在路由层面强制 -3. **易于测试**:策略类可以独立测试,不依赖中间件 -4. **代码复用**:不同接口可以共享相同的认证逻辑 - -## 架构设计 - -### 1. 核心接口 - -**`AuthStrategyInterface`** - 认证策略接口 -```php -interface AuthStrategyInterface -{ - public function authenticate(Request $request): MerchantApp; -} -``` - -### 2. 策略实现 - -#### EpayAuthStrategy(易支付认证) -- 使用 `pid` + `key` + `MD5签名` -- 参数格式:`application/x-www-form-urlencoded` -- 签名算法:MD5(排序后的参数字符串 + KEY) - -#### OpenApiAuthStrategy(OpenAPI认证) -- 使用 `app_id` + `timestamp` + `nonce` + `HMAC-SHA256签名` -- 支持请求头或参数传递 -- 签名算法:HMAC-SHA256(签名字符串, app_secret) - -### 3. 认证服务 - -**`AuthService`** - 认证服务,负责: -- 自动检测接口标准类型 -- 根据类型选择对应的认证策略 -- 支持手动注册新的认证策略 - -```php -// 自动检测 -$app = $authService->authenticate($request); - -// 指定策略类型 -$app = $authService->authenticate($request, 'epay'); - -// 注册新策略 -$authService->registerStrategy('custom', CustomAuthStrategy::class); -``` - -## 使用示例 - -### 控制器中使用 - -```php -class PayController extends BaseController -{ - public function __construct( - protected PayOrderService $payOrderService, - protected AuthService $authService - ) { - } - - public function submit(Request $request) - { - // 自动检测或指定策略类型 - $app = $this->authService->authenticate($request, 'epay'); - - // 使用 $app 进行后续业务处理 - // ... - } -} -``` - -### 添加新的认证策略 - -1. **实现策略接口** -```php -class CustomAuthStrategy implements AuthStrategyInterface -{ - public function authenticate(Request $request): MerchantApp - { - // 实现自定义认证逻辑 - // ... - } -} -``` - -2. **注册策略** -```php -// 在服务提供者或启动文件中 -$authService = new AuthService(); -$authService->registerStrategy('custom', CustomAuthStrategy::class); -``` - -3. **在控制器中使用** -```php -$app = $this->authService->authenticate($request, 'custom'); -``` - -## 自动检测机制 - -`AuthService` 会根据请求特征自动检测接口标准: - -- **易支付**:检测到 `pid` 参数 -- **OpenAPI**:检测到 `X-App-Id` 请求头或 `app_id` 参数 - -如果无法自动检测,可以手动指定策略类型。 - -## 优势对比 - -### 中间件方式(旧方案) -- ❌ 路由配置复杂,每个接口标准需要不同的中间件 -- ❌ 难以在同一路由支持多种认证方式 -- ❌ 扩展新标准需要修改路由配置 - -### 策略模式(新方案) -- ✅ 控制器按需选择认证策略 -- ✅ 同一路由可以支持多种认证方式(通过参数区分) -- ✅ 扩展新标准只需实现策略接口并注册 -- ✅ 代码更清晰,职责分离 - -## 路由配置 - -由于不再使用中间件,路由配置更简洁: - -```php -// 易支付接口 -Route::any('/submit.php', [PayController::class, 'submit']); -Route::post('/mapi.php', [PayController::class, 'mapi']); -Route::get('/api.php', [PayController::class, 'queryOrder']); - -// 所有接口都在控制器内部进行认证,无需中间件 -``` - -## 总结 - -通过策略模式重构认证逻辑,系统具备了: -- **高扩展性**:轻松添加新的接口标准 -- **高灵活性**:控制器可以自由选择认证方式 -- **高可维护性**:代码结构清晰,易于理解和维护 - diff --git a/doc/order_table_design.md b/doc/order_table_design.md deleted file mode 100644 index 17d24ac..0000000 --- a/doc/order_table_design.md +++ /dev/null @@ -1,214 +0,0 @@ -# 支付订单表设计说明 - -## 一、订单表设计原因 - -### 1.1 订单号设计(双重订单号) - -**系统订单号 (`pay_order_id`)** -- **作用**:系统内部唯一标识,用于查询、对账、退款等操作 -- **生成规则**:`P` + `YYYYMMDDHHmmss` + `6位随机数`(如:P20240101120000123456) -- **唯一性**:通过 `uk_pay_order_id` 唯一索引保证 -- **优势**: - - 全局唯一,不受商户影响 - - 便于系统内部查询和关联 - - 对账时作为主键 - -**商户订单号 (`mch_order_no`)** -- **作用**:商户传入的订单号,用于幂等性校验 -- **唯一性**:通过 `uk_mch_order_no(merchant_id, mch_order_no)` 联合唯一索引保证 -- **优势**: - - 同一商户下订单号唯一,防止重复提交 - - 商户侧可以自定义订单号规则 - - 支持商户订单号查询订单 - -**为什么需要两个订单号?** -- 系统订单号:保证全局唯一,便于系统内部管理 -- 商户订单号:保证商户侧唯一,防止重复支付(幂等性) - -### 1.2 关联关系设计 - -**商户与应用关联 (`merchant_id` + `app_id`)** -- **作用**:标识订单所属商户和应用 -- **用途**: - - 权限控制(商户只能查询自己的订单) - - 对账统计(按商户/应用维度) - - 通知路由(根据应用配置的通知地址) - -**支付通道关联 (`channel_id`)** -- **作用**:记录实际使用的支付通道 -- **用途**: - - 退款时找到对应的插件和配置 - - 对账时关联通道信息 - - 统计通道使用情况 - -**支付方式与产品 (`method_code` + `product_code`)** -- **method_code**:支付方式(alipay/wechat/unionpay) - - 用于统计、筛选、报表 -- **product_code**:支付产品(alipay_h5/alipay_life/wechat_jsapi等) - - 由插件根据用户环境自动选择 - - 用于记录实际使用的支付产品 - -### 1.3 金额字段设计 - -**订单金额 (`amount`)** -- 商户实际收款金额(扣除手续费前) -- 用于退款金额校验、对账 - -**手续费 (`fee`)** -- 可选字段,记录通道手续费 -- 用于对账、结算、利润统计 -- 如果不需要详细记录手续费,可以留空或通过 `extra` 存储 - -**币种 (`currency`)** -- 默认 CNY,支持国际化扩展 -- 预留字段,便于后续支持多币种 - -### 1.4 状态流转设计 - -``` -PENDING(待支付) - ├─> SUCCESS(支付成功)← 收到渠道回调并验签通过 - ├─> FAIL(支付失败)← 用户取消、超时、渠道返回失败 - └─> CLOSED(已关闭)← 全额退款后 -``` - -**状态说明**: -- **PENDING**:订单创建后,等待用户支付 -- **SUCCESS**:支付成功,已收到渠道回调并验签通过 -- **FAIL**:支付失败(用户取消、订单超时、渠道返回失败等) -- **CLOSED**:已关闭(全额退款后) - -### 1.5 渠道信息设计 - -**渠道订单号 (`channel_order_no`)** -- 渠道返回的订单号 -- 用于查询订单状态、退款等操作 - -**渠道交易号 (`channel_trade_no`)** -- 部分渠道有交易号概念(如支付宝的 trade_no) -- 用于对账、查询等 - -### 1.6 通知机制设计 - -**通知状态 (`notify_status`)** -- 0:未通知 -- 1:已通知成功 - -**通知次数 (`notify_count`)** -- 记录通知次数,用于重试控制 -- 配合 `ma_notify_task` 表实现异步通知 - -### 1.7 扩展性设计 - -**扩展字段 (`extra`)** -- JSON 格式,存储: - - 支付参数(`pay_params`):前端支付所需的参数 - - 退款信息(`refund_info`):退款结果 - - 自定义字段:业务扩展字段 - -**订单过期时间 (`expire_time`)** -- 用于自动关闭超时订单 -- 默认 30 分钟,可配置 - -## 二、索引设计说明 - -### 2.1 唯一索引 - -- **`uk_pay_order_id`**:保证系统订单号唯一 -- **`uk_mch_order_no(merchant_id, mch_order_no)`**:保证同一商户下商户订单号唯一(幂等性) - -### 2.2 普通索引 - -- **`idx_merchant_app(merchant_id, app_id)`**:商户/应用维度查询 -- **`idx_channel_id`**:通道维度查询 -- **`idx_method_code`**:支付方式维度统计 -- **`idx_status`**:状态筛选 -- **`idx_pay_time`**:按支付时间查询(对账、统计) -- **`idx_created_at`**:按创建时间查询(分页、统计) - -## 三、可能遗漏的字段(后续扩展) - -### 3.1 退款相关字段 - -如果后续需要支持**部分退款**或**多次退款**,可以考虑添加: - -```sql -`refund_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '已退款金额(累计)', -`refund_status` varchar(20) NOT NULL DEFAULT '' COMMENT '退款状态:PENDING-退款中, SUCCESS-退款成功, FAIL-退款失败', -`refund_time` datetime DEFAULT NULL COMMENT '最后退款时间', -``` - -**当前设计**: -- 退款信息存储在 `extra['refund_info']` 中 -- 全额退款后订单状态改为 `CLOSED` -- 如果只需要全额退款,当前设计已足够 - -### 3.2 结算相关字段 - -如果后续需要**分账/结算**功能,可以考虑添加: - -```sql -`settlement_status` varchar(20) NOT NULL DEFAULT '' COMMENT '结算状态:PENDING-待结算, SUCCESS-已结算, FAIL-结算失败', -`settlement_time` datetime DEFAULT NULL COMMENT '结算时间', -`settlement_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '结算金额', -``` - -**当前设计**: -- 结算信息可以通过 `extra` 存储 -- 如果不需要复杂的结算流程,当前设计已足够 - -### 3.3 风控相关字段 - -如果需要**风控功能**,可以考虑添加: - -```sql -`risk_level` varchar(20) NOT NULL DEFAULT '' COMMENT '风险等级:LOW-低, MEDIUM-中, HIGH-高', -`risk_score` int(11) NOT NULL DEFAULT 0 COMMENT '风险评分', -`risk_reason` varchar(255) NOT NULL DEFAULT '' COMMENT '风险原因', -``` - -**当前设计**: -- 风控信息可以通过 `extra` 存储 -- 如果不需要复杂的风控系统,当前设计已足够 - -### 3.4 其他扩展字段 - -- **`user_id`**:用户ID(如果需要关联用户) -- **`device_info`**:设备信息(用于风控) -- **`remark`**:备注(管理员备注) -- **`close_reason`**:关闭原因(用户取消/超时/管理员关闭等) - -## 四、设计原则总结 - -1. **幂等性**:通过 `uk_mch_order_no` 保证同一商户下订单号唯一 -2. **可追溯性**:记录完整的订单信息、渠道信息、时间信息 -3. **可扩展性**:通过 `extra` JSON 字段存储扩展信息 -4. **性能优化**:合理的索引设计,支持常见查询场景 -5. **业务完整性**:覆盖订单全生命周期(创建→支付→退款→关闭) - -## 五、与代码的对应关系 - -| SQL 字段 | 代码字段 | 说明 | -|---------|---------|------| -| `pay_order_id` | `pay_order_id` | 系统订单号 | -| `merchant_id` | `merchant_id` | 商户ID | -| `app_id` | `app_id` | 应用ID | -| `mch_order_no` | `mch_order_no` | 商户订单号 | -| `method_code` | `method_code` | 支付方式 | -| `product_code` | `product_code` | 支付产品 | -| `channel_id` | `channel_id` | 通道ID | -| `amount` | `amount` | 订单金额 | -| `currency` | `currency` | 币种 | -| `status` | `status` | 订单状态 | -| `channel_order_no` | `channel_order_no` | 渠道订单号 | -| `channel_trade_no` | `channel_trade_no` | 渠道交易号 | -| `extra` | `extra` | 扩展字段(JSON) | - -## 六、注意事项 - -1. **字段命名统一**:SQL 和代码中的字段名必须一致 -2. **索引维护**:定期检查索引使用情况,优化慢查询 -3. **数据归档**:历史订单数据量大时,考虑归档策略 -4. **JSON 字段**:`extra` 字段使用 JSON 类型,便于扩展但查询性能略低 -5. **时间字段**:`pay_time`、`expire_time` 等时间字段使用 `datetime` 类型,便于查询和统计 - diff --git a/doc/payment_flow.md b/doc/payment_flow.md deleted file mode 100644 index d3538e6..0000000 --- a/doc/payment_flow.md +++ /dev/null @@ -1,485 +0,0 @@ -# 支付订单发起流程说明 - -## 一、业务系统调用统一下单接口 - -### 1. 接口地址 -``` -POST /api/pay/unifiedOrder -``` - -### 2. 请求头(签名认证) -``` -X-App-Id: app001 # 应用ID -X-Timestamp: 1704067200 # 时间戳(Unix秒) -X-Nonce: abc123xyz # 随机字符串 -X-Signature: calculated_signature # 签名(HMAC-SHA256) -Content-Type: application/json -``` - -### 3. 签名算法 - -**待签名字符串**: -``` -app_id={app_id}×tamp={timestamp}&nonce={nonce}&method=POST&path=/api/pay/unifiedOrder&body_sha256={body_sha256} -``` - -**计算签名**: -```php -$bodySha256 = hash('sha256', json_encode($requestBody)); -$signString = "app_id={app_id}×tamp={timestamp}&nonce={nonce}&method=POST&path=/api/pay/unifiedOrder&body_sha256={bodySha256}"; -$signature = hash_hmac('sha256', $signString, $appSecret); -``` - -### 4. 请求体示例 - -```json -{ - "mch_order_no": "ORDER202401011200001", - "pay_method": "alipay", - "amount": 100.00, - "currency": "CNY", - "subject": "测试商品", - "body": "测试商品描述" -} -``` - -**字段说明**: -- `mch_order_no`:商户订单号(必填,唯一,用于幂等) -- `pay_method`:支付方式(必填,如:alipay、wechat、unionpay) -- `amount`:订单金额(必填,单位:元) -- `currency`:币种(可选,默认:CNY) -- `subject`:订单标题(必填) -- `body`:订单描述(可选) - -### 5. 调用示例(cURL) - -```bash -curl -X POST http://localhost:8787/api/pay/unifiedOrder \ - -H "Content-Type: application/json" \ - -H "X-App-Id: app001" \ - -H "X-Timestamp: 1704067200" \ - -H "X-Nonce: abc123xyz" \ - -H "X-Signature: calculated_signature" \ - -d '{ - "mch_order_no": "ORDER202401011200001", - "pay_method": "alipay", - "amount": 100.00, - "subject": "测试商品", - "body": "测试商品描述" - }' -``` - -### 6. PHP调用示例 - -```php - 'ORDER202401011200001', - 'pay_method' => 'alipay', - 'amount' => 100.00, - 'subject' => '测试商品', - 'body' => '测试商品描述' -]; - -// 计算签名 -$timestamp = time(); -$nonce = uniqid(); -$bodyJson = json_encode($requestBody); -$bodySha256 = hash('sha256', $bodyJson); -$signString = "app_id={$appId}×tamp={$timestamp}&nonce={$nonce}&method=POST&path=/api/pay/unifiedOrder&body_sha256={$bodySha256}"; -$signature = hash_hmac('sha256', $signString, $appSecret); - -// 发送请求 -$ch = curl_init(); -curl_setopt($ch, CURLOPT_URL, $baseUrl . '/api/pay/unifiedOrder'); -curl_setopt($ch, CURLOPT_POST, true); -curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson); -curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); -curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - "X-App-Id: {$appId}", - "X-Timestamp: {$timestamp}", - "X-Nonce: {$nonce}", - "X-Signature: {$signature}", -]); - -$response = curl_exec($ch); -$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); -curl_close($ch); - -$result = json_decode($response, true); -if ($httpCode === 200 && $result['code'] === 200) { - echo "支付订单号:" . $result['data']['pay_order_id'] . "\n"; - echo "支付参数:" . json_encode($result['data']['pay_params'], JSON_UNESCAPED_UNICODE) . "\n"; -} else { - echo "错误:" . $result['msg'] . "\n"; -} -``` - -## 二、服务端处理流程 - -### 流程图 - -```mermaid -sequenceDiagram - participant BizSystem as 业务系统 - participant OpenAPI as OpenAPI接口 - participant AuthMW as 签名中间件 - participant PayService as 订单服务 - participant ChannelRouter as 通道路由 - participant PluginFactory as 插件工厂 - participant Plugin as 支付插件 - participant Channel as 第三方渠道 - - BizSystem->>OpenAPI: POST /api/pay/unifiedOrder - OpenAPI->>AuthMW: 验证签名 - AuthMW->>PayService: 调用统一下单 - PayService->>PayService: 1. 验证商户应用 - PayService->>PayService: 2. 幂等校验 - PayService->>PayService: 3. 创建支付订单 - PayService->>ChannelRouter: 4. 选择通道 - ChannelRouter-->>PayService: 返回通道信息 - PayService->>PluginFactory: 5. 实例化插件 - PluginFactory-->>PayService: 返回插件实例 - PayService->>Plugin: 6. 初始化插件(init) - PayService->>Plugin: 7. 环境检测 - PayService->>Plugin: 8. 调用统一下单(unifiedOrder) - Plugin->>Plugin: 8.1 根据环境选择产品 - Plugin->>Channel: 8.2 调用第三方接口 - Channel-->>Plugin: 返回支付参数 - Plugin-->>PayService: 返回支付结果 - PayService->>PayService: 9. 更新订单信息 - PayService-->>OpenAPI: 返回结果 - OpenAPI-->>BizSystem: 返回支付参数 -``` - -### 详细步骤说明 - -#### 步骤1:签名验证(中间件) -- `OpenApiAuthMiddleware` 验证请求头中的签名 -- 验证时间戳(5分钟内有效) -- 验证签名是否正确 -- 将应用信息注入到请求对象 - -#### 步骤2:验证商户应用 -- 根据 `app_id` 查询 `ma_merchant_app` 表 -- 检查应用状态是否启用 - -#### 步骤3:幂等校验 -- 根据 `merchant_id + mch_order_no` 查询是否已存在订单 -- 如果存在,直接返回已有订单信息(支持幂等) - -#### 步骤4:创建支付订单 -- 生成支付订单号(格式:`P20240101120000123456`) -- 创建 `ma_pay_order` 记录 -- 状态:`PENDING`(待支付) -- 过期时间:30分钟后 - -#### 步骤5:通道路由选择 -- 根据 `merchant_id + app_id + method_code` 查找可用通道 -- 从 `ma_pay_channel` 表中查询 -- 选择第一个可用的通道(后续可扩展权重、容灾策略) - -#### 步骤6:实例化插件 -- 在 `PayService` 中根据 `ma_pay_plugin` 注册表解析插件:优先使用表中的 `class_name`,否则按约定使用 `app\common\payment\{Code}Payment` 实例化插件 -- 例如:`plugin_code = 'lakala'` → 实例化 `LakalaPlugin` - -#### 步骤7:初始化插件 -- 调用 `$plugin->init($methodCode, $channelConfig)` -- 插件内部切换到指定支付方式的配置和逻辑 -- 例如:拉卡拉插件初始化到 `alipay` 模式 - -#### 步骤8:环境检测 -- 从请求头 `User-Agent` 判断用户环境 -- 环境类型: - - `PC`:PC桌面浏览器 - - `H5`:H5手机浏览器 - - `WECHAT`:微信内浏览器 - - `ALIPAY_CLIENT`:支付宝客户端 - -#### 步骤9:调用插件统一下单 -- 调用 `$plugin->unifiedOrder($orderData, $channelConfig, $env)` -- 插件内部处理: - 1. **产品选择**:从通道的 `enabled_products` 中,根据环境自动选择一个产品 - - 例如:H5环境 → 选择 `alipay_h5` - - 例如:支付宝客户端 → 选择 `alipay_life` - 2. **调用第三方接口**:根据产品和支付方式,调用对应的第三方支付接口 - - 例如:拉卡拉插件的支付宝H5接口 - 3. **返回支付参数**:返回给业务系统的支付参数 - -#### 步骤10:更新订单 -- 更新订单的 `product_code`(实际使用的产品) -- 更新订单的 `channel_id` -- 更新订单的 `channel_order_no`(渠道订单号) -- 保存 `pay_params` 到 `extra` 字段 - -## 三、响应数据格式 - -### 成功响应 - -```json -{ - "code": 200, - "msg": "success", - "data": { - "pay_order_id": "P20240101120000123456", - "status": "PENDING", - "pay_params": { - "type": "redirect", - "url": "https://mapi.alipay.com/gateway.do?..." - } - } -} -``` - -### 支付参数类型 - -根据不同的支付产品和环境,`pay_params` 的格式不同: - -#### 1. 跳转支付(H5/PC扫码) -```json -{ - "type": "redirect", - "url": "https://mapi.alipay.com/gateway.do?xxx" -} -``` -业务系统需要:**跳转到该URL** - -#### 2. 表单提交(H5) -```json -{ - "type": "form", - "method": "POST", - "action": "https://mapi.alipay.com/gateway.do", - "fields": { - "app_id": "xxx", - "method": "alipay.trade.wap.pay", - "biz_content": "{...}" - } -} -``` -业务系统需要:**自动提交表单** - -#### 3. JSAPI支付(微信内/支付宝生活号) -```json -{ - "type": "jsapi", - "appId": "wx1234567890", - "timeStamp": "1704067200", - "nonceStr": "abc123", - "package": "prepay_id=wx1234567890", - "signType": "MD5", - "paySign": "calculated_signature" -} -``` -业务系统需要:**调用微信/支付宝JSAPI** - -#### 4. 二维码支付(PC扫码) -```json -{ - "type": "qrcode", - "qrcode_url": "https://qr.alipay.com/xxx", - "qrcode_data": "data:image/png;base64,..." -} -``` -业务系统需要:**展示二维码** - -## 四、用户支付流程 - -### 1. 业务系统处理支付参数 - -根据 `pay_params.type` 进行不同处理: - -```javascript -// 前端处理示例 -const payParams = response.data.pay_params; - -switch (payParams.type) { - case 'redirect': - // 跳转支付 - window.location.href = payParams.url; - break; - - case 'form': - // 表单提交 - const form = document.createElement('form'); - form.method = payParams.method; - form.action = payParams.action; - Object.keys(payParams.fields).forEach(key => { - const input = document.createElement('input'); - input.type = 'hidden'; - input.name = key; - input.value = payParams.fields[key]; - form.appendChild(input); - }); - document.body.appendChild(form); - form.submit(); - break; - - case 'jsapi': - // 微信JSAPI支付 - WeixinJSBridge.invoke('getBrandWCPayRequest', { - appId: payParams.appId, - timeStamp: payParams.timeStamp, - nonceStr: payParams.nonceStr, - package: payParams.package, - signType: payParams.signType, - paySign: payParams.paySign - }, function(res) { - if (res.err_msg === "get_brand_wcpay_request:ok") { - // 支付成功 - } - }); - break; - - case 'qrcode': - // 展示二维码 - document.getElementById('qrcode').src = payParams.qrcode_data; - break; -} -``` - -### 2. 用户完成支付 - -- 用户在第三方支付平台完成支付 -- 第三方平台异步回调到支付中心 - -### 3. 支付中心处理回调 - -- 接收回调:`POST /api/notify/alipay` 或 `/api/notify/wechat` -- 验签:使用插件验证回调签名 -- 更新订单状态:`PENDING` → `SUCCESS` 或 `FAIL` -- 创建通知任务:异步通知业务系统 - -### 4. 业务系统接收通知 - -- 支付中心异步通知业务系统的 `notify_url` -- 业务系统验证签名并处理订单 - -## 五、查询订单接口 - -### 接口地址 -``` -GET /api/pay/query?pay_order_id=P20240101120000123456 -``` - -### 请求头(需要签名) -``` -X-App-Id: app001 -X-Timestamp: 1704067200 -X-Nonce: abc123xyz -X-Signature: calculated_signature -``` - -### 响应示例 - -```json -{ - "code": 200, - "msg": "success", - "data": { - "pay_order_id": "P20240101120000123456", - "mch_order_no": "ORDER202401011200001", - "status": "SUCCESS", - "amount": 100.00, - "pay_time": "2024-01-01 12:00:30" - } -} -``` - -## 六、完整调用示例(Node.js) - -```javascript -const crypto = require('crypto'); -const axios = require('axios'); - -class PaymentClient { - constructor(appId, appSecret, baseUrl) { - this.appId = appId; - this.appSecret = appSecret; - this.baseUrl = baseUrl; - } - - // 计算签名 - calculateSignature(method, path, body, timestamp, nonce) { - const bodySha256 = crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex'); - const signString = `app_id=${this.appId}×tamp=${timestamp}&nonce=${nonce}&method=${method}&path=${path}&body_sha256=${bodySha256}`; - return crypto.createHmac('sha256', this.appSecret).update(signString).digest('hex'); - } - - // 统一下单 - async unifiedOrder(orderData) { - const timestamp = Math.floor(Date.now() / 1000); - const nonce = Math.random().toString(36).substring(7); - const method = 'POST'; - const path = '/api/pay/unifiedOrder'; - - const signature = this.calculateSignature(method, path, orderData, timestamp, nonce); - - const response = await axios.post(`${this.baseUrl}${path}`, orderData, { - headers: { - 'Content-Type': 'application/json', - 'X-App-Id': this.appId, - 'X-Timestamp': timestamp, - 'X-Nonce': nonce, - 'X-Signature': signature - } - }); - - return response.data; - } - - // 查询订单 - async queryOrder(payOrderId) { - const timestamp = Math.floor(Date.now() / 1000); - const nonce = Math.random().toString(36).substring(7); - const method = 'GET'; - const path = `/api/pay/query?pay_order_id=${payOrderId}`; - - const signature = this.calculateSignature(method, path, {}, timestamp, nonce); - - const response = await axios.get(`${this.baseUrl}${path}`, { - headers: { - 'X-App-Id': this.appId, - 'X-Timestamp': timestamp, - 'X-Nonce': nonce, - 'X-Signature': signature - } - }); - - return response.data; - } -} - -// 使用示例 -const client = new PaymentClient('app001', 'your_app_secret', 'http://localhost:8787'); - -// 统一下单 -client.unifiedOrder({ - mch_order_no: 'ORDER202401011200001', - pay_method: 'alipay', - amount: 100.00, - subject: '测试商品', - body: '测试商品描述' -}).then(result => { - console.log('支付参数:', result.data.pay_params); - // 根据 pay_params.type 处理支付 -}).catch(err => { - console.error('下单失败:', err.message); -}); -``` - -## 七、注意事项 - -1. **幂等性**:相同的 `mch_order_no` 多次调用,返回同一订单信息 -2. **签名有效期**:时间戳5分钟内有效 -3. **订单过期**:订单默认30分钟过期 -4. **环境检测**:系统自动根据UA判断环境,选择合适的产品 -5. **异步通知**:支付成功后,系统会异步通知业务系统的 `notify_url` -6. **订单查询**:业务系统可通过查询接口主动查询订单状态 - diff --git a/doc/payment_system_implementation.md b/doc/payment_system_implementation.md deleted file mode 100644 index daa6ec9..0000000 --- a/doc/payment_system_implementation.md +++ /dev/null @@ -1,182 +0,0 @@ -# 支付系统核心实现说明 - -## 概述 - -已实现支付系统核心功能,包括: -- 插件化支付通道系统(支持一个插件多个支付方式) -- OpenAPI统一支付网关 -- 通道管理与配置 -- 订单管理与状态机 -- 异步通知机制 - -## 数据库初始化 - -执行以下SQL脚本创建表结构: - -```bash -mysql -u用户名 -p 数据库名 < database/mvp_payment_tables.sql -``` - -## 核心架构 - -### 1. 插件系统 - -- **插件接口**:`app/common/contracts/PayPluginInterface.php` -- **抽象基类**:`app/common/contracts/AbstractPayPlugin.php`(提供环境检测、产品选择等通用功能) -- **插件类示例**:`app/common/payment/LakalaPayment.php`(命名规范:`XxxPayment`) -- **插件解析**:由 `PayService`、`PayOrderService`、`PluginService` 直接根据 `ma_pay_plugin` 注册表中配置的 `plugin_code` / `class_name` 解析并实例化插件(默认约定类名为 `app\common\payment\{Code}Payment`) - -**插件特点**: -- 一个插件可以支持多个支付方式(如拉卡拉插件支持 alipay/wechat/unionpay) -- **支付产品由插件内部定义**,不需要数据库字典表 -- 插件根据用户环境(PC/H5/微信内/支付宝客户端)自动选择已开通的产品 -- 通道配置中,用户只需勾选确认开启了哪些产品(产品编码由插件定义) -- 有些支付平台不区分产品,插件会根据通道配置自行处理 -- 通道配置表单由插件动态生成 - -### 2. 数据模型 - -- `Merchant`:商户 -- `MerchantApp`:商户应用(AppId/AppSecret) -- `PayMethod`:支付方式(alipay/wechat等) -- `PayChannel`:支付通道(绑定到"插件+支付方式",配置已开通的产品列表) -- `PayOrder`:支付订单 -- `NotifyTask`:商户通知任务 - -**注意**:支付产品不由数据库管理,而是由插件通过 `getSupportedProducts()` 方法定义。通道配置中的 `enabled_products` 字段存储的是用户勾选的产品编码数组。 - -### 3. 服务层 - -- `PayOrderService`:订单业务编排(统一下单、查询) -- `ChannelRouterService`:通道路由选择 -- `NotifyService`:商户通知服务 - -### 4. API接口 - -#### OpenAPI(对外支付网关) - -- `POST /api/pay/unifiedOrder`:统一下单(需要签名认证) -- `GET /api/pay/query`:查询订单(需要签名认证) -- `POST /api/notify/alipay`:支付宝回调 -- `POST /api/notify/wechat`:微信回调 - -#### 管理后台API - -- `GET /adminapi/channel/plugins`:获取所有可用插件 -- `GET /adminapi/channel/plugin/config-schema`:获取插件配置表单Schema -- `GET /adminapi/channel/plugin/products`:获取插件支持的支付产品 -- `GET /adminapi/channel/list`:通道列表 -- `GET /adminapi/channel/detail`:通道详情 -- `POST /adminapi/channel/save`:保存通道 - -## 使用流程 - -### 1. 创建商户和应用 - -```sql -INSERT INTO ma_merchant (merchant_no, merchant_name, funds_mode, status) -VALUES ('M001', '测试商户', 'direct', 1); - -INSERT INTO ma_merchant_app (merchant_id, app_id, app_secret, app_name, notify_url, status) -VALUES (1, 'app001', 'secret_key_here', '测试应用', 'https://example.com/notify', 1); -``` - -### 2. 配置支付通道 - -**配置流程**: -1. 创建通道:选择支付方式、支付插件,配置通道基本信息(显示名称、分成比例、通道成本、通道模式、限额等) -2. 配置插件参数:通道创建后,再配置该通道的插件参数信息(通过插件的配置表单动态生成) - -通过管理后台或直接操作数据库: - -```sql -INSERT INTO ma_pay_channel ( - merchant_id, app_id, channel_code, channel_name, - plugin_code, method_code, enabled_products, config_json, - split_ratio, channel_cost, channel_mode, - daily_limit, daily_count, min_amount, max_amount, - status -) VALUES ( - 1, 1, 'CH001', '拉卡拉-支付宝通道', - 'lakala', 'alipay', - '["alipay_h5", "alipay_life"]', - '{"merchant_id": "lakala_merchant", "secret_key": "xxx", "api_url": "https://api.lakala.com"}', - 100.00, 0.00, 'wallet', - 0.00, 0, NULL, NULL, - 1 -); -``` - -**通道字段说明**: -- `split_ratio`: 分成比例(%),默认100.00 -- `channel_cost`: 通道成本(%),默认0.00 -- `channel_mode`: 通道模式,`wallet`-支付金额扣除手续费后加入商户余额,`direct`-直连到商户 -- `daily_limit`: 单日限额(元),0表示不限制 -- `daily_count`: 单日限笔,0表示不限制 -- `min_amount`: 单笔最小金额(元),NULL表示不限制 -- `max_amount`: 单笔最大金额(元),NULL表示不限制 - -### 3. 调用统一下单接口 - -```bash -curl -X POST http://localhost:8787/api/pay/unifiedOrder \ - -H "X-App-Id: app001" \ - -H "X-Timestamp: 1234567890" \ - -H "X-Nonce: abc123" \ - -H "X-Signature: calculated_signature" \ - -d '{ - "mch_order_no": "ORDER001", - "pay_method": "alipay", - "amount": 100.00, - "subject": "测试订单", - "body": "测试订单描述" - }' -``` - -### 4. 签名算法 - -``` -signString = "app_id={app_id}×tamp={timestamp}&nonce={nonce}&method={method}&path={path}&body_sha256={body_sha256}" -signature = HMAC-SHA256(signString, app_secret) -``` - -## 扩展新插件 - -1. 创建插件类,继承 `AbstractPayPlugin`,并按照 `XxxPayment` 命名放在 `app/common/payment` 目录: - -```php -namespace app\common\payment; - -use app\common\contracts\AbstractPayPlugin; - -class AlipayPayment extends AbstractPayPlugin -{ - public static function getCode(): string { return 'alipay'; } - public static function getName(): string { return '支付宝直连'; } - public static function getSupportedMethods(): array { return ['alipay']; } - // ... 实现其他方法 -} -``` - -2. 在 `ma_pay_plugin` 表中注册插件信息(也可通过后台管理界面维护): - -```sql -INSERT INTO ma_pay_plugin (plugin_code, plugin_name, class_name, status) -VALUES ('alipay', '支付宝直连', 'app\\common\\payment\\AlipayPayment', 1); -``` - -## 注意事项 - -1. **支付产品定义**:支付产品由插件内部通过 `getSupportedProducts()` 方法定义,不需要数据库字典表。通道配置时,用户只需勾选已开通的产品编码。 -2. **环境检测**:插件基类提供 `detectEnvironment()` 方法,可根据UA判断环境 -3. **产品选择**:插件根据环境从通道已开通产品中自动选择。如果通道配置为空或不区分产品,插件会根据配置自行处理。 -4. **通知重试**:使用 `NotifyMerchantJob` 异步重试通知,支持指数退避 -5. **幂等性**:统一下单接口支持幂等,相同 `mch_order_no` 返回已有订单 - -## 后续扩展 - -- 账务系统(账户、分录、余额) -- 结算系统(可结算金额、结算批次、打款) -- 对账系统(渠道账单导入、差异处理) -- 风控系统(规则引擎、风险预警) - diff --git a/doc/project_overview.md b/doc/project_overview.md new file mode 100644 index 0000000..dbd8b86 --- /dev/null +++ b/doc/project_overview.md @@ -0,0 +1,414 @@ +# MPay V2 Project Overview + +更新日期:2026-03-13 + +## 1. 项目定位 + +这是一个基于 Webman 的多商户支付中台项目,当前主要目标是: + +- 提供后台管理能力,维护商户、应用、支付方式、支付插件、支付通道、订单与系统配置 +- 为商户应用提供统一支付能力 +- 当前已优先兼容 `epay` 协议,后续可继续扩展更多外部支付协议 +- 通过“支付插件 + 通道配置”的方式对接第三方渠道 + +结合当前代码与数据库,项目已经具备“多商户 -> 多应用 -> 多通道 -> 多插件”的基础骨架。 + +## 2. 技术栈与运行环境 + +### 后端技术栈 + +- PHP `>= 8.1` +- Webman `^2.1` +- webman/database +- webman/redis +- webman/cache +- webman/console +- webman/captcha +- webman/event +- webman/redis-queue +- firebase/php-jwt +- yansongda/pay `~3.7.0` + +### 当前环境配置要点 + +- HTTP 服务监听:`0.0.0.0:8787` +- 数据库:MySQL +- 缓存与队列:Redis +- 管理后台认证:JWT +- 当前 `.env` 已配置远程 MySQL / Redis 地址,开发前需要确认本机网络可达 + +## 3. 当前环境可调用的 MCP 能力 + +本次会话中,已确认可以直接用于本项目的 MCP / 环境能力如下: + +### MySQL MCP + +- 可直接执行 SQL +- 可读取当前开发库表结构与数据 +- 已确认能访问 `mpay_admin` 相关表,例如: + - `ma_merchant` + - `ma_merchant_app` + - `ma_pay_channel` + - `ma_pay_order` + - `ma_notify_task` + - `ma_callback_inbox` + - `ma_pay_callback_log` + +适合后续继续做: + +- 表结构核对 +- 初始化数据检查 +- 回调与订单状态排查 +- 开发联调时快速确认通道配置 + +### Playwright MCP + +- 可进行浏览器打开、点击、表单填写、快照、截图、网络请求分析 +- 适合后续验证: + - 管理后台登录流程 + - 通道配置页面交互 + - 提交支付后的跳转页/表单页 + - 回调相关前端可视流程 + +### MCP 资源浏览 + +- 可列出 MCP 资源 +- 可读取资源内容 +- 当前未返回资源模板 + +### 非 MCP 但对开发有用的本地能力 + +- Shell 命令执行 +- 工作区文件读写 +- 代码补丁编辑 + +## 4. 业务模型总览 + +### 4.1 商户模型 + +- 表:`ma_merchant` +- 作用:定义商户主体 +- 关键字段: + - `merchant_no` + - `merchant_name` + - `funds_mode` + - `status` + +### 4.2 商户应用模型 + +- 表:`ma_merchant_app` +- 作用:商户可创建多个应用,每个应用具备独立 `app_id` / `app_secret` +- 关键字段: + - `merchant_id` + - `api_type` + - `app_id` + - `app_secret` + - `app_name` + - `status` + +当前代码中,`app_id` 既是应用标识,也是外部协议鉴权入口;`epay` 兼容链路直接用它作为 `pid`。 + +### 4.3 支付方式模型 + +- 表:`ma_pay_method` +- 作用:维护支付方式字典 +- 当前库内数据: + - `alipay` + - `wechat` + - `unionpay` + +### 4.4 支付插件模型 + +- 表:`ma_pay_plugin` +- 作用:把“支付通道配置”与“PHP 插件实现类”解耦 +- 插件需要同时实现: + - `PaymentInterface` + - `PayPluginInterface` + +当前代码里已有两个插件类: + +- `app/common/payment/LakalaPayment.php` +- `app/common/payment/AlipayPayment.php` + +但当前数据库只注册了 `lakala`,还没有把 `alipay` 作为活动插件注册进现网开发库。 + +### 4.5 支付通道模型 + +- 表:`ma_pay_channel` +- 作用:把“商户应用 + 支付方式 + 插件 + 参数配置”绑定起来 +- 关键字段: + - `merchant_id` + - `merchant_app_id` + - `plugin_code` + - `method_id` + - `config_json` + - `split_ratio` + - `chan_cost` + - `chan_mode` + - `daily_limit` + - `daily_cnt` + - `min_amount` + - `max_amount` + - `status` + - `sort` + +这正对应你描述的核心业务特点:一个应用下可配置多个支付通道,每个通道可挂接不同插件与参数。 + +### 4.6 支付订单模型 + +- 表:`ma_pay_order` +- 作用:统一存放系统支付订单 +- 关键特性: + - 系统订单号:`order_id` + - 商户订单号:`mch_order_no` + - 幂等唯一键:`(merchant_id, merchant_app_id, mch_order_no)` + - `extra` JSON 用于存放 `notify_url`、`return_url`、`pay_params`、退款信息等 + +### 4.7 回调与通知模型 + +- `ma_callback_inbox`:回调幂等收件箱 +- `ma_pay_callback_log`:回调日志 +- `ma_notify_task`:商户异步通知任务 + +这三张表说明项目已经为“渠道回调幂等 + 日志留痕 + 商户通知补偿”预留了比较完整的基础设施。 + +## 5. 代码分层与关键入口 + +### 外部接口入口 + +- `app/http/api/controller/EpayController.php` +- `app/http/api/controller/PayController.php` + +### 支付主流程服务 + +- `app/services/api/EpayProtocolService.php` +- `app/services/api/EpayService.php` +- `app/services/PayService.php` +- `app/services/PayOrderService.php` +- `app/services/ChannelRouterService.php` +- `app/services/PluginService.php` +- `app/services/PayNotifyService.php` +- `app/services/NotifyService.php` +- `app/services/PaymentStateService.php` + +### 支付插件契约 + +- `app/common/contracts/PaymentInterface.php` +- `app/common/contracts/PayPluginInterface.php` +- `app/common/base/BasePayment.php` + +### 管理后台接口 + +- 商户:`MerchantController` +- 商户应用:`MerchantAppController` +- 支付方式:`PayMethodController` +- 插件注册:`PayPluginController` +- 通道:`ChannelController` +- 订单:`OrderController` +- 系统配置:`SystemController` +- 登录认证:`AuthController` + +## 6. 当前已落地的对外接口 + +### 路由现状 + +当前 `app/routes/api.php` 实际挂载的对外接口为: + +- `GET|POST /submit.php` +- `POST /mapi.php` +- `GET /api.php` +- `ANY /notify/{pluginCode}` + +### 兼容协议现状 + +当前真正已打通的是 `epay` 风格接口: + +- `submit.php`:页面跳转支付 +- `mapi.php`:API 下单 +- `api.php?act=order`:查单 +- `api.php?act=refund`:退款 + +### OpenAPI 现状 + +`PayController` 中存在以下方法: + +- `create` +- `query` +- `close` +- `refund` + +但当前都还是 `501 not implemented`,并且对应路由尚未挂载,因此“通用 OpenAPI”目前仍是预留骨架,不是已上线能力。 + +## 7. 核心支付链路 + +### 7.1 Epay 下单链路 + +1. 商户调用 `submit.php` 或 `mapi.php` +2. `EpayProtocolService` 负责参数提取与校验 +3. `EpayService` 使用 `app_secret` 做 MD5 验签 +4. 构造统一内部订单数据 +5. `PayOrderService` 创建订单,并通过联合唯一键保证幂等 +6. `ChannelRouterService` 根据 `merchant_id + merchant_app_id + method_id` 选取通道 +7. `PluginService` 从注册表解析插件类并实例化 +8. 插件执行 `pay()` +9. `PayService` 回写: + - `channel_id` + - `chan_order_no` + - `chan_trade_no` + - `fee` + - `real_amount` + - `extra.pay_params` +10. 转换成 `epay` 所需返回结构给调用方 + +### 7.2 回调处理链路 + +1. 第三方渠道回调 `/notify/{pluginCode}` +2. `PayNotifyService` 调插件 `notify()` 验签与解析 +3. 通过 `ma_callback_inbox` 做幂等去重 +4. 状态机更新订单状态 +5. 写入回调日志 +6. 创建商户通知任务 + +### 7.3 商户通知链路 + +1. `NotifyService` 根据订单 `extra.notify_url` 创建通知任务 +2. 通知内容写入 `ma_notify_task` +3. `sendNotify()` 使用 HTTP POST JSON 回调商户 +4. 若商户返回 HTTP 200 且 body 为 `success`,视为通知成功 + +## 8. 插件与通道现状 + +### `LakalaPayment` + +状态:示例插件 / mock 插件 + +现状: + +- `pay()` 已实现,但只是返回模拟二维码字符串 +- `query()` 未实现 +- `close()` 未实现 +- `refund()` 未实现 +- `notify()` 未实现 + +这意味着当前库里虽然已经能“创建订单并拿到拉起参数”,但还不能完成真实的拉卡拉闭环。 + +### `AlipayPayment` + +状态:代码层面相对完整 + +已实现: + +- `pay()` +- `query()` +- `close()` +- `refund()` +- `notify()` + +特点: + +- 基于 `yansongda/pay` +- 支持产品类型: + - `alipay_web` + - `alipay_h5` + - `alipay_scan` + - `alipay_app` +- 可根据环境自动选产品 + +注意: + +- 当前开发库没有注册 `alipay` 插件记录 +- 当前通道也没有指向 `AlipayPayment` + +所以它虽然写在代码里,但当前数据库并没有真正启用它。 + +## 9. 管理后台现状 + +后台已经覆盖以下核心维护能力: + +- 验证码登录 + JWT 鉴权 +- 商户管理 +- 商户应用管理 +- 支付方式管理 +- 支付插件注册管理 +- 支付通道管理 +- 订单列表 / 详情 / 退款 +- 系统基础配置管理 + +这部分说明“支付中心后台”已经不是空架子,而是可以承接后续运营配置的。 + +## 10. 当前开发库快照(基于 2026-03-13 实际查询) + +### 数据量 + +- `ma_admin`: 1 +- `ma_merchant`: 1 +- `ma_merchant_app`: 1 +- `ma_pay_method`: 3 +- `ma_pay_plugin`: 1 +- `ma_pay_channel`: 2 +- `ma_pay_order`: 1 +- `ma_notify_task`: 0 +- `ma_callback_inbox`: 0 +- `ma_pay_callback_log`: 0 + +### 当前商户与应用 + +- 商户:`M001 / 测试商户` +- 应用:`1001 / 测试应用-易支付` +- 应用类型:`epay` + +### 当前活动插件 + +- `lakala -> app\\common\\payment\\LakalaPayment` + +### 当前通道 + +- `lakala_alipay` +- `lakala_wechat` + +### 当前示例订单 + +- 订单号:`P20260312160833644578` +- 商户单号:`TEST123` +- 状态:`PENDING` +- 通道:`channel_id = 1` +- `extra.pay_params` 为 mock 二维码 + +## 11. 当前代码与需求的对应关系 + +你给出的项目特点,与当前实现的对应情况如下: + +### 已匹配的部分 + +- 多商户:已支持 +- 一个商户多个应用:已支持 +- 一个应用多个支付通道:已支持 +- 通道可绑定支付方式:已支持 +- 通道可绑定支付插件:已支持 +- 通道可存储插件参数:已支持 +- 通道可配置手续费:已支持,当前会参与 `fee` / `real_amount` 计算 +- 商户通过 `APPID` 发起支付:已支持,当前主要在 `epay` 兼容链路中落地 +- 创建订单并调用第三方插件:已支持 + +### 仅完成“数据建模”,尚未完全落地执行的部分 + +- 每日限额:字段已存在,但当前下单/路由流程未校验 +- 每日笔数限制:字段已存在,但当前未校验 +- 最小/最大金额限制:字段已存在,但当前未校验 +- 更复杂的路由策略:当前仅按 `sort` 取第一条可用通道 +- 多协议统一 OpenAPI:控制器骨架存在,但未真正接入 + +## 12. 后续阅读建议 + +如果下一次继续开发,建议优先从以下文件继续进入: + +- 支付入口:`app/http/api/controller/EpayController.php` +- 协议适配:`app/services/api/EpayProtocolService.php` +- 业务主流程:`app/services/PayService.php` +- 订单创建:`app/services/PayOrderService.php` +- 回调处理:`app/services/PayNotifyService.php` +- 插件管理:`app/services/PluginService.php` +- 拉卡拉插件:`app/common/payment/LakalaPayment.php` +- 支付宝插件:`app/common/payment/AlipayPayment.php` +- 通道配置:`app/http/admin/controller/ChannelController.php` + diff --git a/doc/project_progress.md b/doc/project_progress.md new file mode 100644 index 0000000..26a1ba9 --- /dev/null +++ b/doc/project_progress.md @@ -0,0 +1,320 @@ +# MPay V2 Development Progress + +更新日期:2026-03-13 + +本文档用于记录当前项目完成度、明显缺口和建议推进顺序,方便后续继续开发时快速接手。 + +## 1. 当前总体判断 + +项目已经完成了“支付中台基础骨架 + 后台配置能力 + Epay 协议首条链路”的主体搭建。 + +更准确地说: + +- 数据模型已经比较完整 +- 后台配置能力已经具备可用性 +- 支付流程主链路已经跑通到“下单并返回拉起参数” +- 真正需要继续补的是“真实渠道闭环、规则执行、异步补偿、通用协议扩展” + +## 2. 已完成 + +### 2.1 基础框架与环境 + +- Webman 项目骨架已搭建 +- MySQL / Redis / JWT / Cache / Event / Redis Queue 依赖已接入 +- 管理后台与 API 路由已拆分 + +### 2.2 管理后台能力 + +- 验证码登录 +- JWT 鉴权中间件 +- 管理员信息查询 +- 菜单与系统配置读取 +- 商户 CRUD +- 商户应用 CRUD +- 支付方式 CRUD +- 支付插件注册 CRUD +- 支付通道 CRUD +- 订单列表 / 详情 / 后台发起退款 + +### 2.3 核心支付数据结构 + +已建表并落地: + +- 商户 +- 商户应用 +- 支付方式 +- 插件注册 +- 支付通道 +- 支付订单 +- 回调日志 +- 商户通知任务 +- 回调幂等收件箱 + +### 2.4 下单主链路 + +已打通: + +- Epay 参数校验 +- Epay MD5 验签 +- 商户应用识别 +- 幂等订单创建 +- 通道路由 +- 插件实例化 +- 插件下单 +- 订单回写支付参数 +- 返回兼容 Epay 的响应结构 + +### 2.5 支付状态基础设施 + +- 订单状态机服务已存在 +- 成功 / 失败 / 全额退款关单状态迁移已定义 +- 回调日志记录能力已存在 +- 回调幂等收件箱已存在 +- 商户通知任务创建逻辑已存在 + +### 2.6 插件体系 + +已建立统一插件契约: + +- `PaymentInterface` +- `PayPluginInterface` +- `BasePayment` + +说明后续继续接入新渠道时,整体扩展方式已经明确。 + +## 3. 部分完成 + +### 3.1 Epay 兼容是“主路径”,但还不是“全量兼容” + +当前已实现: + +- `submit.php` +- `mapi.php` +- `api.php?act=order` +- `api.php?act=refund` + +但 `doc/epay.md` 中提到的一些能力,如 `query`、`settle`、`orders` 等,代码中暂未实现。 + +### 3.2 支付宝插件代码较完整,但未在当前数据库启用 + +现状: + +- `AlipayPayment.php` 已实现 +- 当前开发库 `ma_pay_plugin` 中只有 `lakala` + +这意味着支付宝更多处于“代码已写好、配置未接入”的状态。 + +### 3.3 回调后通知商户的基础逻辑存在,但补偿闭环还不完整 + +已完成: + +- 创建通知任务 +- 发送通知 +- 失败重试时间计算 + +待确认 / 待补齐: + +- 当前没有看到明确的任务投递入口 +- 也没有看到定时调度 `NotifyMerchantJob` 的配置闭环 +- `NotifyMerchantJob` 虽然存在,但尚未形成明确的可运行消费链路 + +更保守地说,商户通知补偿链路还没有真正闭环。 + +## 4. 待完成 + +### 4.1 通用 OpenAPI + +当前状态: + +- `PayController` 只有骨架 +- `create/query/close/refund` 都返回 `501` +- `OpenApiAuthMiddleware` 已存在,但未挂到路由 + +建议判断:这是下一阶段最适合补完的能力之一。 + +### 4.2 拉卡拉真实对接 + +当前状态: + +- `LakalaPayment::pay()` 只返回 mock 二维码 +- `query/close/refund/notify` 全部未实现 + +影响: + +- 现在只能用于打通订单创建流程 +- 还不能进行真实线上支付联调 + +### 4.3 通道路由规则执行 + +数据库已设计的字段很多,但运行期并未全部生效: + +- `daily_limit` 未校验 +- `daily_cnt` 未校验 +- `min_amount` 未校验 +- `max_amount` 未校验 +- `split_ratio` 当前只存储,未看到清算分账逻辑 +- 路由策略目前只是“按排序取第一条可用通道” + +这块是项目从“能下单”走向“可运营”的关键缺口。 + +### 4.4 Epay 协议映射细节 + +当前内部支付方式代码使用: + +- `alipay` +- `wechat` + +但传统 Epay 常见值通常还有: + +- `wxpay` +- `qqpay` + +当前代码里没有看到统一别名映射层,说明“协议兼容”仍偏接口形态兼容,而不是完整字段语义兼容。 + +### 4.5 插件注册与初始化数据同步 + +代码、SQL、数据库现状存在轻微偏差: + +- `database/dev_seed.sql` 里准备了 `alipay` 和 `lakala` +- 当前开发库只看到 `lakala` + +建议后续把“代码存在但数据库未启用”的状态统一起来,减少联调歧义。 + +### 4.6 通道安全与敏感配置 + +当前通道配置直接存在 `config_json` 中,后续建议补充: + +- 敏感字段加密存储 +- 后台展示脱敏 +- 配置变更审计日志 + +### 4.7 测试体系 + +当前仓库里没有看到成体系的: + +- 单元测试 +- 协议测试 +- 插件对接测试 +- 回调幂等测试 +- 退款回归测试 + +这会让后续迭代的回归成本越来越高。 + +## 5. 风险与注意点 + +### 5.1 当前“多通道”能力更偏配置层,而不是调度层 + +虽然表结构和后台已经支持多通道,但运行时路由还比较简单,不能完全体现: + +- 限额控制 +- 金额区间控制 +- 通道健康度切换 +- 优先级与容灾 + +### 5.2 退款能力目前偏基础版 + +当前退款服务已存在,但从实现上看: + +- 更适合单次退款 / 全额退款 +- 全额退款后直接把订单关闭 +- 没有独立退款单模型 +- 没有完整的部分退款累计能力 + +### 5.3 回调成功后的订单与通知一致性要继续加强 + +当前已经有: + +- 幂等收件箱 +- 状态机 +- 通知任务表 + +这是很好的基础。 + +但真正生产级还建议再补: + +- 事务边界说明 +- 异常重放工具 +- 回调人工补单工具 +- 通知签名 + +## 6. 建议优先级 + +### P0:优先补完,直接影响可用性 + +1. 实现真实渠道插件,至少先补完一个可联调通道 +2. 补完 OpenAPI 主链路 +3. 在路由阶段执行金额限制 / 限额 / 笔数规则 +4. 打通商户通知任务的实际调度与重试闭环 + +### P1:补齐可运营能力 + +1. 增加支付方式别名映射,提升 Epay 兼容度 +2. 把 `AlipayPayment` 正式接入插件注册与通道配置 +3. 增加后台对通道能力、产品、环境的可视化说明 +4. 增加日志检索与问题排查手段 + +### P2:走向平台化 + +1. 增加更多协议兼容层 +2. 增加清算 / 分账 / 对账 +3. 增加风控规则 +4. 增加监控、告警、报表 + +## 7. 建议后续开发方向 + +### 方向一:先做“一个真实可用通道” + +建议优先把某一个通道做成完整闭环: + +- 下单 +- 回调 +- 查单 +- 关单 +- 退款 + +这样项目就能从“框架完成”升级为“真实可上线联调”。 + +### 方向二:补通用 OpenAPI + +原因: + +- 你已经明确后续可能兼容更多接口 +- 当前通用控制器和鉴权中间件已经有雏形 +- 补完之后,项目会从“单协议适配器”升级为“统一支付网关” + +### 方向三:把通道路由做成真正的策略引擎 + +建议把下面这些字段从“仅存储”升级为“真实执行”: + +- 金额范围 +- 单日限额 +- 单日限笔 +- 通道优先级 +- 通道健康状态 +- 权重或降级策略 + +### 方向四:补测试与排障工具 + +优先建议增加: + +- 下单幂等测试 +- 回调幂等测试 +- 退款状态测试 +- 协议字段兼容测试 +- 一键重发通知工具 + +## 8. 推荐继续开发顺序 + +如果下一次直接继续往下做,我建议按这个顺序推进: + +1. 选定一个真实渠道作为首个闭环目标 +2. 补完该插件的 `notify/query/refund/close` +3. 接入并验证商户通知补偿链路 +4. 在 `ChannelRouterService` 前后补齐通道规则校验 +5. 正式实现 `PayController` +6. 抽象协议适配层,准备支持更多接口 +7. 增加测试与后台排障能力 + +## 9. 当前一句话结论 + +这是一个“骨架已经成型、第一条协议已打通、非常适合继续往生产级推进”的支付中台项目;下一阶段的重点不是重写,而是把已有设计真正补成闭环。