From 9de902231ffd4f3d4d4fba938ef622f5de9bde75 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: Tue, 10 Mar 2026 13:47:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/common/contracts/AbstractPayPlugin.php | 229 +++++++++ app/common/contracts/PayPluginInterface.php | 103 ++++ app/common/payment/LakalaPayment.php | 180 +++++++ app/http/admin/controller/AdminController.php | 34 ++ .../admin/controller/ChannelController.php | 164 ++++++ .../admin/controller/PluginController.php | 71 +++ app/http/admin/controller/UserController.php | 40 -- app/http/api/controller/EpayController.php | 132 +++++ app/http/api/controller/PayController.php | 41 ++ .../api/middleware/EpayAuthMiddleware.php | 69 +++ .../api/middleware/OpenApiAuthMiddleware.php | 69 +++ app/jobs/NotifyMerchantJob.php | 51 ++ app/models/Admin.php | 36 ++ app/models/Merchant.php | 27 + app/models/MerchantApp.php | 30 ++ app/models/PaymentCallbackLog.php | 33 ++ app/models/PaymentChannel.php | 62 +++ app/models/PaymentMethod.php | 30 ++ app/models/PaymentNotifyTask.php | 42 ++ app/models/PaymentOrder.php | 62 +++ app/models/PaymentPlugin.php | 34 ++ app/models/User.php | 30 -- app/repositories/AdminRepository.php | 31 ++ app/repositories/MerchantAppRepository.php | 41 ++ app/repositories/MerchantRepository.php | 28 + .../PaymentCallbackLogRepository.php | 22 + app/repositories/PaymentChannelRepository.php | 38 ++ app/repositories/PaymentMethodRepository.php | 34 ++ .../PaymentNotifyTaskRepository.php | 34 ++ app/repositories/PaymentOrderRepository.php | 56 ++ app/repositories/PaymentPluginRepository.php | 32 ++ app/repositories/UserRepository.php | 47 -- app/routes/admin.php | 30 +- app/routes/api.php | 14 +- app/services/AdminService.php | 37 ++ app/services/AuthService.php | 97 +--- app/services/ChannelRouterService.php | 42 ++ app/services/NotifyService.php | 121 +++++ app/services/PayOrderService.php | 182 +++++++ app/services/PayService.php | 161 ++++++ app/services/PluginService.php | 122 +++++ app/services/UserService.php | 44 -- app/services/api/EpayService.php | 288 +++++++++++ app/validation/EpayValidator.php | 122 +++++ config/process.php | 2 +- database/ma_system_config.sql | 11 - database/mvp_payment_tables.sql | 248 +++++++++ doc/auth_strategy_design.md | 145 ++++++ doc/epay.md | 216 ++++++++ doc/order_table_design.md | 214 ++++++++ doc/payment_flow.md | 485 ++++++++++++++++++ doc/payment_system_implementation.md | 182 +++++++ doc/skill.md | 481 +++++++++-------- doc/validation.md | 395 ++++++++++++++ 54 files changed, 5070 insertions(+), 501 deletions(-) create mode 100644 app/common/contracts/AbstractPayPlugin.php create mode 100644 app/common/contracts/PayPluginInterface.php create mode 100644 app/common/payment/LakalaPayment.php create mode 100644 app/http/admin/controller/AdminController.php create mode 100644 app/http/admin/controller/ChannelController.php create mode 100644 app/http/admin/controller/PluginController.php delete mode 100644 app/http/admin/controller/UserController.php create mode 100644 app/http/api/controller/EpayController.php create mode 100644 app/http/api/controller/PayController.php create mode 100644 app/http/api/middleware/EpayAuthMiddleware.php create mode 100644 app/http/api/middleware/OpenApiAuthMiddleware.php create mode 100644 app/jobs/NotifyMerchantJob.php create mode 100644 app/models/Admin.php create mode 100644 app/models/Merchant.php create mode 100644 app/models/MerchantApp.php create mode 100644 app/models/PaymentCallbackLog.php create mode 100644 app/models/PaymentChannel.php create mode 100644 app/models/PaymentMethod.php create mode 100644 app/models/PaymentNotifyTask.php create mode 100644 app/models/PaymentOrder.php create mode 100644 app/models/PaymentPlugin.php delete mode 100644 app/models/User.php create mode 100644 app/repositories/AdminRepository.php create mode 100644 app/repositories/MerchantAppRepository.php create mode 100644 app/repositories/MerchantRepository.php create mode 100644 app/repositories/PaymentCallbackLogRepository.php create mode 100644 app/repositories/PaymentChannelRepository.php create mode 100644 app/repositories/PaymentMethodRepository.php create mode 100644 app/repositories/PaymentNotifyTaskRepository.php create mode 100644 app/repositories/PaymentOrderRepository.php create mode 100644 app/repositories/PaymentPluginRepository.php delete mode 100644 app/repositories/UserRepository.php create mode 100644 app/services/AdminService.php create mode 100644 app/services/ChannelRouterService.php create mode 100644 app/services/NotifyService.php create mode 100644 app/services/PayOrderService.php create mode 100644 app/services/PayService.php create mode 100644 app/services/PluginService.php delete mode 100644 app/services/UserService.php create mode 100644 app/services/api/EpayService.php create mode 100644 app/validation/EpayValidator.php delete mode 100644 database/ma_system_config.sql create mode 100644 database/mvp_payment_tables.sql create mode 100644 doc/auth_strategy_design.md create mode 100644 doc/epay.md create mode 100644 doc/order_table_design.md create mode 100644 doc/payment_flow.md create mode 100644 doc/payment_system_implementation.md create mode 100644 doc/validation.md diff --git a/app/common/contracts/AbstractPayPlugin.php b/app/common/contracts/AbstractPayPlugin.php new file mode 100644 index 0000000..62f201e --- /dev/null +++ b/app/common/contracts/AbstractPayPlugin.php @@ -0,0 +1,229 @@ +currentMethod = $methodCode; + $this->currentConfig = $channelConfig; + } + + /** + * 检测请求环境 + * + * @param Request $request + * @return string 环境代码(PC/H5/WECHAT/ALIPAY_CLIENT) + */ + protected function detectEnvironment(Request $request): string + { + $ua = strtolower($request->header('User-Agent', '')); + + // 支付宝客户端 + if (strpos($ua, 'alipayclient') !== false) { + return self::ENV_ALIPAY_CLIENT; + } + + // 微信内浏览器 + if (strpos($ua, 'micromessenger') !== false) { + return self::ENV_WECHAT; + } + + // 移动设备 + $mobileKeywords = ['mobile', 'android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone']; + foreach ($mobileKeywords as $keyword) { + if (strpos($ua, $keyword) !== false) { + return self::ENV_H5; + } + } + + // 默认PC + return self::ENV_PC; + } + + /** + * 根据环境选择产品 + * + * @param array $enabledProducts 已启用的产品列表 + * @param string $env 环境代码 + * @param array $allProducts 所有可用产品(产品代码 => 产品名称) + * @return string|null 选择的产品代码,如果没有匹配则返回null + */ + protected function selectProductByEnv(array $enabledProducts, string $env, array $allProducts): ?string + { + // 环境到产品的映射规则(子类可以重写此方法实现自定义逻辑) + $envProductMap = [ + self::ENV_PC => ['pc', 'web', 'wap'], + self::ENV_H5 => ['h5', 'wap', 'mobile'], + self::ENV_WECHAT => ['jsapi', 'wechat', 'h5'], + self::ENV_ALIPAY_CLIENT => ['app', 'alipay', 'h5'], + ]; + + $candidates = $envProductMap[$env] ?? []; + + // 优先匹配已启用的产品 + foreach ($candidates as $candidate) { + if (in_array($candidate, $enabledProducts)) { + return $candidate; + } + } + + // 如果没有匹配,返回第一个已启用的产品 + if (!empty($enabledProducts)) { + return $enabledProducts[0]; + } + + return null; + } + + /** + * HTTP POST JSON请求 + * + * @param string $url 请求URL + * @param array $data 请求数据 + * @param array $headers 额外请求头 + * @return array 响应数据(已解析JSON) + */ + protected function httpPostJson(string $url, array $data, array $headers = []): array + { + $headers['Content-Type'] = 'application/json'; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $this->buildHeaders($headers)); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error) { + throw new \RuntimeException("HTTP请求失败:{$error}"); + } + + if ($httpCode !== 200) { + throw new \RuntimeException("HTTP请求失败,状态码:{$httpCode}"); + } + + $result = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException("JSON解析失败:" . json_last_error_msg()); + } + + return $result; + } + + /** + * HTTP POST Form请求 + * + * @param string $url 请求URL + * @param array $data 请求数据 + * @param array $headers 额外请求头 + * @return string 响应内容 + */ + protected function httpPostForm(string $url, array $data, array $headers = []): string + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $this->buildHeaders($headers)); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error) { + throw new \RuntimeException("HTTP请求失败:{$error}"); + } + + if ($httpCode !== 200) { + throw new \RuntimeException("HTTP请求失败,状态码:{$httpCode}"); + } + + return $response; + } + + /** + * 构建HTTP请求头数组 + * + * @param array $headers 请求头数组 + * @return array + */ + private function buildHeaders(array $headers): array + { + $result = []; + foreach ($headers as $key => $value) { + $result[] = "{$key}: {$value}"; + } + return $result; + } + + /** + * 记录请求日志 + * + * @param string $action 操作名称 + * @param array $data 请求数据 + * @param mixed $response 响应数据 + * @return void + */ + protected function logRequest(string $action, array $data, $response = null): void + { + $logData = [ + 'plugin' => static::getCode(), + 'method' => $this->currentMethod, + 'action' => $action, + 'request' => $data, + 'response' => $response, + 'time' => date('Y-m-d H:i:s'), + ]; + + Log::debug('支付插件请求', $logData); + } +} + diff --git a/app/common/contracts/PayPluginInterface.php b/app/common/contracts/PayPluginInterface.php new file mode 100644 index 0000000..12fcf92 --- /dev/null +++ b/app/common/contracts/PayPluginInterface.php @@ -0,0 +1,103 @@ + 支付方式代码数组,如 ['alipay', 'wechat'] + */ + public static function getSupportedMethods(): array; + + /** + * 获取指定支付方式支持的产品列表 + * + * @param string $methodCode 支付方式代码 + * @return array 产品代码 => 产品名称 + */ + public static function getSupportedProducts(string $methodCode): array; + + /** + * 获取指定支付方式的配置表单结构 + * + * @param string $methodCode 支付方式代码 + * @return array 表单字段定义数组 + */ + public static function getConfigSchema(string $methodCode): array; + + /** + * 初始化插件(切换到指定支付方式) + * + * @param string $methodCode 支付方式代码 + * @param array $channelConfig 通道配置 + * @return void + */ + public function init(string $methodCode, array $channelConfig): void; + + /** + * 统一下单 + * + * @param array $orderData 订单数据 + * @param array $channelConfig 通道配置 + * @param string $requestEnv 请求环境(PC/H5/WECHAT/ALIPAY_CLIENT) + * @return array 支付结果,包含: + * - product_code: 选择的产品代码 + * - channel_order_no: 渠道订单号(如果有) + * - pay_params: 支付参数(根据产品类型不同,结构不同) + */ + public function unifiedOrder(array $orderData, array $channelConfig, string $requestEnv): array; + + /** + * 查询订单 + * + * @param array $orderData 订单数据(至少包含 pay_order_id 或 channel_order_no) + * @param array $channelConfig 通道配置 + * @return array 订单状态信息 + */ + public function query(array $orderData, array $channelConfig): array; + + /** + * 退款 + * + * @param array $refundData 退款数据 + * @param array $channelConfig 通道配置 + * @return array 退款结果 + */ + public function refund(array $refundData, array $channelConfig): array; + + /** + * 解析回调通知 + * + * @param array $requestData 回调请求数据 + * @param array $channelConfig 通道配置 + * @return array 解析结果,包含: + * - status: 订单状态(SUCCESS/FAIL/PENDING) + * - pay_order_id: 系统订单号 + * - channel_trade_no: 渠道交易号 + * - amount: 支付金额 + * - pay_time: 支付时间 + */ + public function parseNotify(array $requestData, array $channelConfig): array; +} + diff --git a/app/common/payment/LakalaPayment.php b/app/common/payment/LakalaPayment.php new file mode 100644 index 0000000..24e7e74 --- /dev/null +++ b/app/common/payment/LakalaPayment.php @@ -0,0 +1,180 @@ + [ + ['code' => 'alipay_h5', 'name' => '支付宝H5', 'device_type' => 'H5'], + ['code' => 'alipay_life', 'name' => '支付宝生活号', 'device_type' => 'ALIPAY_CLIENT'], + ['code' => 'alipay_app', 'name' => '支付宝APP', 'device_type' => 'ALIPAY_CLIENT'], + ['code' => 'alipay_qr', 'name' => '支付宝扫码', 'device_type' => 'PC'], + ], + 'wechat' => [ + ['code' => 'wechat_jsapi', 'name' => '微信JSAPI', 'device_type' => 'WECHAT'], + ['code' => 'wechat_h5', 'name' => '微信H5', 'device_type' => 'H5'], + ['code' => 'wechat_native', 'name' => '微信扫码', 'device_type' => 'PC'], + ['code' => 'wechat_app', 'name' => '微信APP', 'device_type' => 'H5'], + ], + 'unionpay' => [ + ['code' => 'unionpay_h5', 'name' => '云闪付H5', 'device_type' => 'H5'], + ['code' => 'unionpay_app', 'name' => '云闪付APP', 'device_type' => 'H5'], + ], + default => [], + }; + } + + /** + * 获取配置Schema + */ + public static function getConfigSchema(string $methodCode): array + { + $baseFields = [ + ['field' => 'merchant_id', 'label' => '商户号', 'type' => 'input', 'required' => true], + ['field' => 'secret_key', 'label' => '密钥', 'type' => 'input', 'required' => true], + ['field' => 'api_url', 'label' => '接口地址', 'type' => 'input', 'required' => true], + ]; + + // 根据支付方式添加特定字段 + if ($methodCode === 'alipay') { + $baseFields[] = ['field' => 'alipay_app_id', 'label' => '支付宝AppId', 'type' => 'input']; + } elseif ($methodCode === 'wechat') { + $baseFields[] = ['field' => 'wechat_app_id', 'label' => '微信AppId', 'type' => 'input']; + } + + return ['fields' => $baseFields]; + } + + /** + * 统一下单 + */ + public function unifiedOrder(array $orderData, array $channelConfig, string $requestEnv): array + { + // 1. 从通道已开通产品中选择(根据环境) + $enabledProducts = $channelConfig['enabled_products'] ?? []; + $allProducts = static::getSupportedProducts($this->currentMethod); + $productCode = $this->selectProductByEnv($enabledProducts, $requestEnv, $allProducts); + + if (!$productCode) { + throw new \RuntimeException('当前环境无可用支付产品'); + } + + // 2. 根据当前支付方式和产品调用不同的接口 + // 这里简化处理,实际应调用拉卡拉的API + return match ($this->currentMethod) { + 'alipay' => $this->createAlipayOrder($orderData, $channelConfig, $productCode), + 'wechat' => $this->createWechatOrder($orderData, $channelConfig, $productCode), + 'unionpay' => $this->createUnionpayOrder($orderData, $channelConfig, $productCode), + default => throw new \RuntimeException('未初始化的支付方式'), + }; + } + + /** + * 查询订单 + */ + public function query(array $orderData, array $channelConfig): array + { + // TODO: 实现查询逻辑 + return ['status' => 'PENDING']; + } + + /** + * 退款 + */ + public function refund(array $refundData, array $channelConfig): array + { + // TODO: 实现退款逻辑 + return ['status' => 'SUCCESS']; + } + + /** + * 解析回调 + */ + public function parseNotify(array $requestData, array $channelConfig): array + { + // TODO: 实现回调解析和验签 + return [ + 'status' => 'SUCCESS', + 'pay_order_id' => $requestData['out_trade_no'] ?? '', + 'channel_trade_no'=> $requestData['trade_no'] ?? '', + 'amount' => $requestData['total_amount'] ?? 0, + ]; + } + + private function createAlipayOrder(array $orderData, array $config, string $productCode): array + { + // TODO: 调用拉卡拉的支付宝接口 + return [ + 'product_code' => $productCode, + 'channel_order_no'=> '', + 'pay_params' => [ + 'type' => 'redirect', + 'url' => 'https://example.com/pay?order=' . $orderData['pay_order_id'], + ], + ]; + } + + private function createWechatOrder(array $orderData, array $config, string $productCode): array + { + // TODO: 调用拉卡拉的微信接口 + return [ + 'product_code' => $productCode, + 'channel_order_no'=> '', + 'pay_params' => [ + 'type' => 'jsapi', + 'appId' => $config['wechat_app_id'] ?? '', + 'timeStamp' => time(), + 'nonceStr' => uniqid(), + 'package' => 'prepay_id=xxx', + 'signType' => 'MD5', + 'paySign' => 'xxx', + ], + ]; + } + + private function createUnionpayOrder(array $orderData, array $config, string $productCode): array + { + // TODO: 调用拉卡拉的云闪付接口 + return [ + 'product_code' => $productCode, + 'channel_order_no'=> '', + 'pay_params' => [ + 'type' => 'redirect', + 'url' => 'https://example.com/unionpay?order=' . $orderData['pay_order_id'], + ], + ]; + } +} + + diff --git a/app/http/admin/controller/AdminController.php b/app/http/admin/controller/AdminController.php new file mode 100644 index 0000000..4faeecd --- /dev/null +++ b/app/http/admin/controller/AdminController.php @@ -0,0 +1,34 @@ +currentUserId($request); + if ($adminId <= 0) { + return $this->fail('未获取到用户信息,请先登录', 401); + } + + $data = $this->adminService->getInfoById($adminId); + return $this->success($data); + } +} diff --git a/app/http/admin/controller/ChannelController.php b/app/http/admin/controller/ChannelController.php new file mode 100644 index 0000000..90c3e2a --- /dev/null +++ b/app/http/admin/controller/ChannelController.php @@ -0,0 +1,164 @@ +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); + } + + /** + * 通道详情 + * GET /adminapi/channel/detail + */ + public function detail(Request $request) + { + $id = (int)$request->get('id', 0); + if (!$id) { + 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 : ''; + } + + try { + $configSchema = $this->pluginService->getConfigSchema($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']]; + } + } + } + + return $this->success([ + 'channel' => $channel, + 'config_schema' => $configSchema, + ]); + } catch (\Throwable $e) { + return $this->success([ + 'channel' => $channel, + 'config_schema' => ['fields' => []], + ]); + } + } + + /** + * 保存通道 + * 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'] ?? ''; + $enabledProducts = $data['enabled_products'] ?? []; + + if (empty($pluginCode) || empty($methodCode)) { + 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'] ?? '', + '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, + '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) { + // 更新 + $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); + } + + return $this->success(null, '保存成功'); + } +} + diff --git a/app/http/admin/controller/PluginController.php b/app/http/admin/controller/PluginController.php new file mode 100644 index 0000000..6a6d3f7 --- /dev/null +++ b/app/http/admin/controller/PluginController.php @@ -0,0 +1,71 @@ +pluginService->listPlugins(); + return $this->success($plugins); + } + + /** + * 获取插件配置Schema + * GET /adminapi/channel/plugin/config-schema + */ + public function configSchema(Request $request) + { + $pluginCode = $request->get('plugin_code', ''); + $methodCode = $request->get('method_code', ''); + + if (empty($pluginCode) || empty($methodCode)) { + return $this->fail('插件编码和支付方式不能为空', 400); + } + + try { + $schema = $this->pluginService->getConfigSchema($pluginCode, $methodCode); + return $this->success($schema); + } catch (\Throwable $e) { + return $this->fail('获取配置Schema失败:' . $e->getMessage(), 400); + } + } + + /** + * 获取插件支持的支付产品列表 + * GET /adminapi/channel/plugin/products + */ + public function products(Request $request) + { + $pluginCode = $request->get('plugin_code', ''); + $methodCode = $request->get('method_code', ''); + + if (empty($pluginCode) || empty($methodCode)) { + return $this->fail('插件编码和支付方式不能为空', 400); + } + + try { + $products = $this->pluginService->getSupportedProducts($pluginCode, $methodCode); + return $this->success($products); + } catch (\Throwable $e) { + return $this->fail('获取产品列表失败:' . $e->getMessage(), 400); + } + } +} + diff --git a/app/http/admin/controller/UserController.php b/app/http/admin/controller/UserController.php deleted file mode 100644 index 1936989..0000000 --- a/app/http/admin/controller/UserController.php +++ /dev/null @@ -1,40 +0,0 @@ -currentUserId($request); - - if ($userId <= 0) { - return $this->fail('未获取到用户信息,请先登录', 401); - } - - $data = $this->userService->getUserInfoById($userId); - return $this->success($data); - } -} - diff --git a/app/http/api/controller/EpayController.php b/app/http/api/controller/EpayController.php new file mode 100644 index 0000000..7d9963d --- /dev/null +++ b/app/http/api/controller/EpayController.php @@ -0,0 +1,132 @@ +get(), $request->post()); + + try { + // 参数校验(使用自定义 Validator + 场景) + $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 redirect($payParams['url']); + } + + if (($payParams['type'] ?? '') === 'form') { + return $this->renderForm($payParams); + } + + // 如果没有匹配的类型,返回错误 + return $this->fail('支付参数生成失败'); + } catch (\Throwable $e) { + return $this->fail($e->getMessage()); + } + } + + /** + * API接口支付 + */ + 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); + } catch (\Throwable $e) { + return json([ + 'code' => 0, + 'msg' => $e->getMessage(), + ]); + } + } + + /** + * API接口 + */ + 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); + } catch (\Throwable $e) { + return json([ + 'code' => 0, + 'msg' => $e->getMessage(), + ]); + } + } + + /** + * 渲染表单提交 HTML(用于页面跳转支付) + */ + private function renderForm(array $formParams): Response + { + $html = '跳转支付'; + $html .= '
'; + + if (isset($formParams['fields']) && is_array($formParams['fields'])) { + foreach ($formParams['fields'] as $name => $value) { + $html .= ''; + } + } + + $html .= '
'; + $html .= ''; + $html .= ''; + + return response($html)->withHeaders(['Content-Type' => 'text/html; charset=UTF-8']); + } +} diff --git a/app/http/api/controller/PayController.php b/app/http/api/controller/PayController.php new file mode 100644 index 0000000..5a0e4fe --- /dev/null +++ b/app/http/api/controller/PayController.php @@ -0,0 +1,41 @@ +merchantAppRepository = new MerchantAppRepository(); + } + + public function process(Request $request, callable $handler): Response + { + $appId = $request->header('X-App-Id', '') ?: ($request->post('app_id', '') ?: $request->get('app_id', '')); + $timestamp = $request->header('X-Timestamp', '') ?: ($request->post('timestamp', '') ?: $request->get('timestamp', '')); + $nonce = $request->header('X-Nonce', '') ?: ($request->post('nonce', '') ?: $request->get('nonce', '')); + $signature = $request->header('X-Signature', '') ?: ($request->post('signature', '') ?: $request->get('signature', '')); + + if (empty($appId) || empty($timestamp) || empty($nonce) || empty($signature)) { + throw new UnauthorizedException('缺少认证参数'); + } + + // 验证时间戳(5分钟内有效) + if (abs(time() - (int)$timestamp) > 300) { + throw new UnauthorizedException('请求已过期'); + } + + // 查询应用 + $app = $this->merchantAppRepository->findByAppId($appId); + if (!$app) { + throw new UnauthorizedException('应用不存在或已禁用'); + } + + // 验证签名 + $method = $request->method(); + $path = $request->path(); + $body = $request->rawBody(); + $bodySha256 = hash('sha256', $body); + + $signString = "app_id={$appId}×tamp={$timestamp}&nonce={$nonce}&method={$method}&path={$path}&body_sha256={$bodySha256}"; + $expectedSignature = hash_hmac('sha256', $signString, $app->app_secret); + + if (!hash_equals($expectedSignature, $signature)) { + throw new UnauthorizedException('签名验证失败'); + } + + // 将应用信息注入到请求对象 + $request->app = $app; + $request->merchantId = $app->merchant_id; + $request->appId = $app->id; + + return $handler($request); + } +} + diff --git a/app/http/api/middleware/OpenApiAuthMiddleware.php b/app/http/api/middleware/OpenApiAuthMiddleware.php new file mode 100644 index 0000000..14cd5e1 --- /dev/null +++ b/app/http/api/middleware/OpenApiAuthMiddleware.php @@ -0,0 +1,69 @@ +merchantAppRepository = new MerchantAppRepository(); + } + + public function process(Request $request, callable $handler): Response + { + $appId = $request->header('X-App-Id', '') ?: ($request->post('app_id', '') ?: $request->get('app_id', '')); + $timestamp = $request->header('X-Timestamp', '') ?: ($request->post('timestamp', '') ?: $request->get('timestamp', '')); + $nonce = $request->header('X-Nonce', '') ?: ($request->post('nonce', '') ?: $request->get('nonce', '')); + $signature = $request->header('X-Signature', '') ?: ($request->post('signature', '') ?: $request->get('signature', '')); + + if (empty($appId) || empty($timestamp) || empty($nonce) || empty($signature)) { + throw new UnauthorizedException('缺少认证参数'); + } + + // 验证时间戳(5分钟内有效) + if (abs(time() - (int)$timestamp) > 300) { + throw new UnauthorizedException('请求已过期'); + } + + // 查询应用 + $app = $this->merchantAppRepository->findByAppId($appId); + if (!$app) { + throw new UnauthorizedException('应用不存在或已禁用'); + } + + // 验证签名 + $method = $request->method(); + $path = $request->path(); + $body = $request->rawBody(); + $bodySha256 = hash('sha256', $body); + + $signString = "app_id={$appId}×tamp={$timestamp}&nonce={$nonce}&method={$method}&path={$path}&body_sha256={$bodySha256}"; + $expectedSignature = hash_hmac('sha256', $signString, $app->app_secret); + + if (!hash_equals($expectedSignature, $signature)) { + throw new UnauthorizedException('签名验证失败'); + } + + // 将应用信息注入到请求对象 + $request->app = $app; + $request->merchantId = $app->merchant_id; + $request->appId = $app->id; + + return $handler($request); + } +} + diff --git a/app/jobs/NotifyMerchantJob.php b/app/jobs/NotifyMerchantJob.php new file mode 100644 index 0000000..0f70010 --- /dev/null +++ b/app/jobs/NotifyMerchantJob.php @@ -0,0 +1,51 @@ +notifyTaskRepository->getPendingRetryTasks(100); + + foreach ($tasks as $taskData) { + try { + $task = $this->notifyTaskRepository->find($taskData['id']); + if (!$task) { + continue; + } + + if ($task->retry_cnt >= 10) { + $this->notifyTaskRepository->updateById($task->id, [ + 'status' => PaymentNotifyTask::STATUS_FAIL, + ]); + continue; + } + + $this->notifyService->sendNotify($task); + } catch (\Throwable $e) { + Log::error('通知任务处理失败', [ + 'task_id' => $taskData['id'] ?? 0, + 'error' => $e->getMessage(), + ]); + } + } + } +} + diff --git a/app/models/Admin.php b/app/models/Admin.php new file mode 100644 index 0000000..513c7bd --- /dev/null +++ b/app/models/Admin.php @@ -0,0 +1,36 @@ + 'integer', + 'login_at' => 'datetime', + ]; + + protected $hidden = ['password']; +} diff --git a/app/models/Merchant.php b/app/models/Merchant.php new file mode 100644 index 0000000..944c0f3 --- /dev/null +++ b/app/models/Merchant.php @@ -0,0 +1,27 @@ + 'integer', + ]; +} + diff --git a/app/models/MerchantApp.php b/app/models/MerchantApp.php new file mode 100644 index 0000000..86bbe0e --- /dev/null +++ b/app/models/MerchantApp.php @@ -0,0 +1,30 @@ + 'integer', + 'status' => 'integer', + ]; +} + diff --git a/app/models/PaymentCallbackLog.php b/app/models/PaymentCallbackLog.php new file mode 100644 index 0000000..845cc2e --- /dev/null +++ b/app/models/PaymentCallbackLog.php @@ -0,0 +1,33 @@ + 'integer', + 'verify_status' => 'integer', + 'process_status' => 'integer', + ]; +} diff --git a/app/models/PaymentChannel.php b/app/models/PaymentChannel.php new file mode 100644 index 0000000..17b8ed2 --- /dev/null +++ b/app/models/PaymentChannel.php @@ -0,0 +1,62 @@ + 'integer', + 'merchant_app_id' => 'integer', + 'method_id' => 'integer', + 'config_json' => 'array', + 'split_ratio' => 'decimal:2', + 'chan_cost' => 'decimal:2', + 'daily_limit' => 'decimal:2', + 'daily_cnt' => 'integer', + 'min_amount' => 'decimal:2', + 'max_amount' => 'decimal:2', + 'status' => 'integer', + 'sort' => 'integer', + ]; + + public function getConfigArray(): array + { + return $this->config_json ?? []; + } + + public function getEnabledProducts(): array + { + $config = $this->getConfigArray(); + return $config['enabled_products'] ?? []; + } +} diff --git a/app/models/PaymentMethod.php b/app/models/PaymentMethod.php new file mode 100644 index 0000000..f38dea0 --- /dev/null +++ b/app/models/PaymentMethod.php @@ -0,0 +1,30 @@ + 'integer', + 'status' => 'integer', + ]; +} diff --git a/app/models/PaymentNotifyTask.php b/app/models/PaymentNotifyTask.php new file mode 100644 index 0000000..8684639 --- /dev/null +++ b/app/models/PaymentNotifyTask.php @@ -0,0 +1,42 @@ + 'integer', + 'merchant_app_id' => 'integer', + 'retry_cnt' => 'integer', + 'next_retry_at' => 'datetime', + 'last_notify_at' => 'datetime', + ]; + + const STATUS_PENDING = 'PENDING'; + const STATUS_SUCCESS = 'SUCCESS'; + const STATUS_FAIL = 'FAIL'; +} diff --git a/app/models/PaymentOrder.php b/app/models/PaymentOrder.php new file mode 100644 index 0000000..4296c0f --- /dev/null +++ b/app/models/PaymentOrder.php @@ -0,0 +1,62 @@ + 'integer', + 'merchant_app_id' => 'integer', + 'method_id' => 'integer', + 'channel_id' => 'integer', + 'amount' => 'decimal:2', + 'real_amount' => 'decimal:2', + 'fee' => 'decimal:2', + 'status' => 'integer', + 'notify_stat' => 'integer', + 'notify_cnt' => 'integer', + 'extra' => 'array', + 'pay_at' => 'datetime', + 'expire_at' => 'datetime', + ]; + + const STATUS_PENDING = 0; + const STATUS_SUCCESS = 1; + const STATUS_FAIL = 2; + const STATUS_CLOSED = 3; +} diff --git a/app/models/PaymentPlugin.php b/app/models/PaymentPlugin.php new file mode 100644 index 0000000..1c5cb47 --- /dev/null +++ b/app/models/PaymentPlugin.php @@ -0,0 +1,34 @@ + 'integer', + ]; +} diff --git a/app/models/User.php b/app/models/User.php deleted file mode 100644 index 2f1e837..0000000 --- a/app/models/User.php +++ /dev/null @@ -1,30 +0,0 @@ -belongsToMany(Role::class, 'role_user', 'user_id', 'role_id'); - } -} - - diff --git a/app/repositories/AdminRepository.php b/app/repositories/AdminRepository.php new file mode 100644 index 0000000..e321caa --- /dev/null +++ b/app/repositories/AdminRepository.php @@ -0,0 +1,31 @@ +model + ->newQuery() + ->where('user_name', $userName) + ->first(); + + return $admin; + } +} diff --git a/app/repositories/MerchantAppRepository.php b/app/repositories/MerchantAppRepository.php new file mode 100644 index 0000000..299bd20 --- /dev/null +++ b/app/repositories/MerchantAppRepository.php @@ -0,0 +1,41 @@ +model->newQuery() + ->where('app_id', $appId) + ->where('status', 1) + ->first(); + } + + /** + * 根据商户ID和应用ID查询 + */ + public function findByMerchantAndApp(int $merchantId, int $appId): ?MerchantApp + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->where('id', $appId) + ->where('status', 1) + ->first(); + } +} + diff --git a/app/repositories/MerchantRepository.php b/app/repositories/MerchantRepository.php new file mode 100644 index 0000000..5a147b5 --- /dev/null +++ b/app/repositories/MerchantRepository.php @@ -0,0 +1,28 @@ +model->newQuery() + ->where('merchant_no', $merchantNo) + ->first(); + } +} + diff --git a/app/repositories/PaymentCallbackLogRepository.php b/app/repositories/PaymentCallbackLogRepository.php new file mode 100644 index 0000000..c08f2bf --- /dev/null +++ b/app/repositories/PaymentCallbackLogRepository.php @@ -0,0 +1,22 @@ +model->newQuery()->create($data); + } +} diff --git a/app/repositories/PaymentChannelRepository.php b/app/repositories/PaymentChannelRepository.php new file mode 100644 index 0000000..a3fc5c9 --- /dev/null +++ b/app/repositories/PaymentChannelRepository.php @@ -0,0 +1,38 @@ +model->newQuery() + ->where('merchant_id', $merchantId) + ->where('merchant_app_id', $merchantAppId) + ->where('method_id', $methodId) + ->where('status', 1) + ->orderBy('sort', 'asc') + ->first(); + } + + public function findByChanCode(string $chanCode): ?PaymentChannel + { + return $this->model->newQuery() + ->where('chan_code', $chanCode) + ->first(); + } +} diff --git a/app/repositories/PaymentMethodRepository.php b/app/repositories/PaymentMethodRepository.php new file mode 100644 index 0000000..d7bbd78 --- /dev/null +++ b/app/repositories/PaymentMethodRepository.php @@ -0,0 +1,34 @@ +model->newQuery() + ->where('status', 1) + ->orderBy('sort', 'asc') + ->get() + ->toArray(); + } + + public function findByCode(string $methodCode): ?PaymentMethod + { + return $this->model->newQuery() + ->where('method_code', $methodCode) + ->where('status', 1) + ->first(); + } +} diff --git a/app/repositories/PaymentNotifyTaskRepository.php b/app/repositories/PaymentNotifyTaskRepository.php new file mode 100644 index 0000000..57b4cb2 --- /dev/null +++ b/app/repositories/PaymentNotifyTaskRepository.php @@ -0,0 +1,34 @@ +model->newQuery() + ->where('order_id', $orderId) + ->first(); + } + + public function getPendingRetryTasks(int $limit = 100): array + { + return $this->model->newQuery() + ->where('status', PaymentNotifyTask::STATUS_PENDING) + ->where('next_retry_at', '<=', date('Y-m-d H:i:s')) + ->limit($limit) + ->get() + ->toArray(); + } +} diff --git a/app/repositories/PaymentOrderRepository.php b/app/repositories/PaymentOrderRepository.php new file mode 100644 index 0000000..5f3e3a8 --- /dev/null +++ b/app/repositories/PaymentOrderRepository.php @@ -0,0 +1,56 @@ +model->newQuery() + ->where('order_id', $orderId) + ->first(); + } + + /** + * 根据商户订单号查询(幂等校验) + */ + public function findByMchNo(int $merchantId, int $merchantAppId, string $mchOrderNo): ?PaymentOrder + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->where('merchant_app_id', $merchantAppId) + ->where('mch_order_no', $mchOrderNo) + ->first(); + } + + public function updateStatus(string $orderId, int $status, array $extra = []): bool + { + $data = array_merge(['status' => $status], $extra); + $order = $this->findByOrderId($orderId); + return $order ? $this->updateById($order->id, $data) : false; + } + + public function updateChannelInfo(string $orderId, string $chanOrderNo, string $chanTradeNo = ''): bool + { + $order = $this->findByOrderId($orderId); + if (!$order) { + return false; + } + $data = ['chan_order_no' => $chanOrderNo]; + if ($chanTradeNo !== '') { + $data['chan_trade_no'] = $chanTradeNo; + } + return $this->updateById($order->id, $data); + } +} diff --git a/app/repositories/PaymentPluginRepository.php b/app/repositories/PaymentPluginRepository.php new file mode 100644 index 0000000..6560453 --- /dev/null +++ b/app/repositories/PaymentPluginRepository.php @@ -0,0 +1,32 @@ +model->newQuery() + ->where('status', 1) + ->get(['plugin_code', 'class_name']); + } + + public function findActiveByCode(string $pluginCode): ?PaymentPlugin + { + return $this->model->newQuery() + ->where('plugin_code', $pluginCode) + ->where('status', 1) + ->first(); + } +} diff --git a/app/repositories/UserRepository.php b/app/repositories/UserRepository.php deleted file mode 100644 index 7613ff1..0000000 --- a/app/repositories/UserRepository.php +++ /dev/null @@ -1,47 +0,0 @@ -model - ->newQuery() - ->where('user_name', $userName) - ->first(); - - return $user; - } - - /** - * 根据主键查询并预加载角色 - */ - public function findWithRoles(int $id): ?User - { - /** @var User|null $user */ - $user = $this->model - ->newQuery() - ->with('roles') - ->find($id); - - return $user; - } -} - - diff --git a/app/routes/admin.php b/app/routes/admin.php index b0f4ab0..81cc6b5 100644 --- a/app/routes/admin.php +++ b/app/routes/admin.php @@ -9,31 +9,43 @@ use Webman\Route; use app\http\admin\controller\AuthController; -use app\http\admin\controller\UserController; +use app\http\admin\controller\AdminController; use app\http\admin\controller\MenuController; use app\http\admin\controller\SystemController; +use app\http\admin\controller\ChannelController; +use app\http\admin\controller\PluginController; use app\common\middleware\Cors; use app\http\admin\middleware\AuthMiddleware; Route::group('/adminapi', function () { // 认证相关(无需JWT验证) - Route::get('/captcha', [AuthController::class, 'captcha']); - Route::post('/login', [AuthController::class, 'login']); + Route::get('/captcha', [AuthController::class, 'captcha'])->name('captcha')->setParams(['real_name' => '验证码']); + Route::post('/login', [AuthController::class, 'login'])->name('login')->setParams(['real_name' => '登录']); // 需要认证的路由组 Route::group('', function () { // 用户相关(需要JWT验证) - Route::get('/user/getUserInfo', [UserController::class, 'getUserInfo']); + Route::get('/user/getUserInfo', [AdminController::class, 'getUserInfo'])->name('getUserInfo')->setParams(['real_name' => '获取管理员信息']); // 菜单相关(需要JWT验证) - Route::get('/menu/getRouters', [MenuController::class, 'getRouters']); + Route::get('/menu/getRouters', [MenuController::class, 'getRouters'])->name('getRouters')->setParams(['real_name' => '获取菜单']); // 系统相关(需要JWT验证) - Route::get('/system/getDict[/{code}]', [SystemController::class, 'getDict']); + Route::get('/system/getDict[/{code}]', [SystemController::class, 'getDict'])->name('getDict')->setParams(['real_name' => '获取字典']); // 系统配置相关(需要JWT验证) - Route::get('/system/base-config/tabs', [SystemController::class, 'getTabsConfig']); - Route::get('/system/base-config/form/{tabKey}', [SystemController::class, 'getFormConfig']); - Route::post('/system/base-config/submit/{tabKey}', [SystemController::class, 'submitConfig']); + 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' => '获取插件产品列表']); })->middleware([AuthMiddleware::class]); })->middleware([Cors::class]); \ No newline at end of file diff --git a/app/routes/api.php b/app/routes/api.php index 67b84ef..8ef1bd5 100644 --- a/app/routes/api.php +++ b/app/routes/api.php @@ -1,8 +1,20 @@ middleware([EpayAuthMiddleware::class]); diff --git a/app/services/AdminService.php b/app/services/AdminService.php new file mode 100644 index 0000000..5a87cc5 --- /dev/null +++ b/app/services/AdminService.php @@ -0,0 +1,37 @@ + array, 'roles' => array, 'permissions' => array] + */ + public function getInfoById(int $id): array + { + $admin = $this->adminRepository->find($id); + if (!$admin) { + throw new NotFoundException('管理员不存在'); + } + + return [ + 'user' => $admin->toArray(), + 'roles' => ['admin'], + 'permissions' => ['*:*:*'], + ]; + } +} diff --git a/app/services/AuthService.php b/app/services/AuthService.php index 09487eb..0e149f2 100644 --- a/app/services/AuthService.php +++ b/app/services/AuthService.php @@ -5,26 +5,25 @@ namespace app\services; use app\common\base\BaseService; use app\common\utils\JwtUtil; use app\exceptions\{BadRequestException, ForbiddenException, UnauthorizedException}; -use app\repositories\UserRepository; +use app\models\Admin; +use app\repositories\AdminRepository; use support\Cache; /** * 认证服务 * - * 处理登录、token 生成等认证相关业务 + * 处理管理员登录、token 生成等认证相关业务 */ class AuthService extends BaseService { public function __construct( - protected UserRepository $userRepository, + protected AdminRepository $adminRepository, protected CaptchaService $captchaService ) { } /** - * 用户登录 - * - * 登录成功后返回 token,前端使用该 token 通过 Authorization 请求头访问需要认证的接口 + * 管理员登录 * * @param string $username 用户名 * @param string $password 密码 @@ -34,110 +33,64 @@ class AuthService extends BaseService */ public function login(string $username, string $password, string $verifyCode, string $captchaId): array { - // 1. 校验验证码 if (!$this->captchaService->validate($captchaId, $verifyCode)) { throw new BadRequestException('验证码错误或已失效'); } - // 2. 查询用户 - $user = $this->userRepository->findByUserName($username); - if (!$user) { + $admin = $this->adminRepository->findByUserName($username); + if (!$admin) { throw new UnauthorizedException('账号或密码错误'); } - // 3. 校验密码 - if (!$this->validatePassword($password, $user->password)) { + if (!$this->validatePassword($password, $admin->password)) { throw new UnauthorizedException('账号或密码错误'); } - // 4. 检查用户状态 - if ($user->status !== 1) { + if ($admin->status !== 1) { throw new ForbiddenException('账号已被禁用'); } - // 5. 生成 JWT token(包含用户ID、用户名、昵称等信息) - $token = $this->generateToken($user); + $token = $this->generateToken($admin); + $this->cacheToken($token, $admin->id); + $this->updateLoginInfo($admin); - // 6. 将 token 信息存入 Redis(用于后续刷新、黑名单等) - $this->cacheToken($token, $user->id); - - // 7. 更新用户最后登录信息 - $this->updateLoginInfo($user); - - // 返回 token,前端使用该 token 访问需要认证的接口 - return [ - 'token' => $token, - ]; + return ['token' => $token]; } - /** - * 校验密码 - * - * @param string $password 明文密码 - * @param string|null $hash 数据库中的密码hash - * @return bool - */ private function validatePassword(string $password, ?string $hash): bool { - // 如果数据库密码为空,允许使用默认密码(仅用于开发/演示) if ($hash === null || $hash === '') { - // 开发环境:允许 admin/123456 和 common/123456 无密码登录 - // 生产环境应移除此逻辑 return in_array($password, ['123456'], true); } - return password_verify($password, $hash); } - /** - * 生成 JWT token - * - * @param \app\models\User $user - * @return string - */ - private function generateToken($user): string + private function generateToken(Admin $admin): string { $payload = [ - 'user_id' => $user->id, - 'user_name' => $user->user_name, - 'nick_name' => $user->nick_name, + 'user_id' => $admin->id, + 'user_name' => $admin->user_name, + 'nick_name' => $admin->nick_name, ]; - return JwtUtil::generateToken($payload); } - /** - * 将 token 信息缓存到 Redis - * - * @param string $token - * @param int $userId - */ - private function cacheToken(string $token, int $userId): void + private function cacheToken(string $token, int $adminId): void { $key = JwtUtil::getCachePrefix() . $token; - $data = [ - 'user_id' => $userId, - 'created_at' => time(), - ]; + $data = ['user_id' => $adminId, 'created_at' => time()]; Cache::set($key, $data, JwtUtil::getTtl()); } - /** - * 更新用户登录信息 - * - * @param \app\models\User $user - */ - private function updateLoginInfo($user): void + private function updateLoginInfo(Admin $admin): void { - // 获取客户端真实IP(优先使用 x-real-ip,其次 x-forwarded-for,最后 remoteIp) $request = request(); - $ip = $request->header('x-real-ip', '') - ?: ($request->header('x-forwarded-for', '') ? explode(',', $request->header('x-forwarded-for', ''))[0] : '') + $ip = $request->header('x-real-ip', '') + ?: ($request->header('x-forwarded-for', '') ? trim(explode(',', $request->header('x-forwarded-for', ''))[0]) : '') ?: $request->getRemoteIp(); - - $user->login_ip = trim($ip); - $user->login_at = date('Y-m-d H:i:s'); - $user->save(); + $admin->login_ip = trim($ip); + $admin->login_at = date('Y-m-d H:i:s'); + $admin->save(); } } diff --git a/app/services/ChannelRouterService.php b/app/services/ChannelRouterService.php new file mode 100644 index 0000000..79abd82 --- /dev/null +++ b/app/services/ChannelRouterService.php @@ -0,0 +1,42 @@ +channelRepository->findAvailableChannel($merchantId, $merchantAppId, $methodId); + + if (!$channel) { + throw new NotFoundException("未找到可用的支付通道:商户ID={$merchantId}, 应用ID={$merchantAppId}, 支付方式ID={$methodId}"); + } + + return $channel; + } +} + diff --git a/app/services/NotifyService.php b/app/services/NotifyService.php new file mode 100644 index 0000000..149e4a6 --- /dev/null +++ b/app/services/NotifyService.php @@ -0,0 +1,121 @@ +orderRepository->findByOrderId($orderId); + if (!$order) { + return; + } + + $existing = $this->notifyTaskRepository->findByOrderId($orderId); + if ($existing) { + return; + } + + $notifyUrl = $order->extra['notify_url'] ?? ''; + if (empty($notifyUrl)) { + Log::warning('订单缺少 notify_url,跳过创建通知任务', ['order_id' => $orderId]); + return; + } + + $this->notifyTaskRepository->create([ + 'order_id' => $orderId, + 'merchant_id' => $order->merchant_id, + 'merchant_app_id' => $order->merchant_app_id, + 'notify_url' => $notifyUrl, + 'notify_data' => json_encode([ + 'order_id' => $order->order_id, + 'mch_order_no' => $order->mch_order_no, + 'status' => $order->status, + 'amount' => $order->amount, + 'pay_time' => $order->pay_at, + ], JSON_UNESCAPED_UNICODE), + 'status' => PaymentNotifyTask::STATUS_PENDING, + 'retry_cnt' => 0, + 'next_retry_at' => date('Y-m-d H:i:s'), + ]); + } + + /** + * 发送通知 + */ + public function sendNotify(PaymentNotifyTask $task): bool + { + try { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $task->notify_url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $task->notify_data); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + ]); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $success = ($httpCode === 200 && strtolower(trim($response)) === 'success'); + + $this->notifyTaskRepository->updateById($task->id, [ + 'status' => $success ? PaymentNotifyTask::STATUS_SUCCESS : PaymentNotifyTask::STATUS_PENDING, + 'retry_cnt' => $task->retry_cnt + 1, + 'last_notify_at' => date('Y-m-d H:i:s'), + 'last_response' => $response, + 'next_retry_at' => $success ? null : $this->calculateNextRetryTime($task->retry_cnt + 1), + ]); + + return $success; + } catch (\Throwable $e) { + Log::error('发送通知失败', [ + 'task_id' => $task->id, + 'error' => $e->getMessage(), + ]); + + $this->notifyTaskRepository->updateById($task->id, [ + 'retry_cnt' => $task->retry_cnt + 1, + 'last_notify_at' => date('Y-m-d H:i:s'), + 'last_response' => $e->getMessage(), + 'next_retry_at' => $this->calculateNextRetryTime($task->retry_cnt + 1), + ]); + + return false; + } + } + + /** + * 计算下次重试时间(指数退避) + */ + private function calculateNextRetryTime(int $retryCount): string + { + $intervals = [60, 300, 900, 3600]; // 1分钟、5分钟、15分钟、1小时 + $interval = $intervals[min($retryCount - 1, count($intervals) - 1)] ?? 3600; + return date('Y-m-d H:i:s', time() + $interval); + } +} + diff --git a/app/services/PayOrderService.php b/app/services/PayOrderService.php new file mode 100644 index 0000000..00dc74b --- /dev/null +++ b/app/services/PayOrderService.php @@ -0,0 +1,182 @@ +methodRepository->findByCode($methodCode); + if (!$method) { + throw new BadRequestException('支付方式不存在'); + } + + // 3. 幂等校验:同一商户应用下相同商户订单号只保留一条 + $existing = $this->orderRepository->findByMchNo($mchId, $appId, $mchNo); + if ($existing) { + return $existing; + } + + // 4. 生成系统订单号 + $orderId = $this->generateOrderId(); + + // 5. 创建订单 + return $this->orderRepository->create([ + 'order_id' => $orderId, + 'merchant_id' => $mchId, + 'merchant_app_id' => $appId, + 'mch_order_no' => $mchNo, + 'method_id' => $method->id, + 'channel_id' => $data['channel_id'] ?? $data['chan_id'] ?? 0, + 'amount' => $amount, + 'real_amount' => $amount, + 'fee' => $data['fee'] ?? 0.00, + 'subject' => $subject, + 'body' => $data['body'] ?? $subject, + 'status' => PaymentOrder::STATUS_PENDING, + 'client_ip' => $data['client_ip'] ?? '', + 'expire_at' => $data['expire_at'] ?? $data['expire_time'] ?? date('Y-m-d H:i:s', time() + 1800), + 'extra' => $data['extra'] ?? [], + ]); + } + + /** + * 订单退款(供易支付等接口调用) + * + * @param array $data + * - order_id: 系统订单号(必填) + * - refund_amount: 退款金额(必填) + * - refund_reason: 退款原因(可选) + * @return array + */ + public function refundOrder(array $data): array + { + $orderId = (string)($data['order_id'] ?? $data['pay_order_id'] ?? ''); + $refundAmount = (float)($data['refund_amount'] ?? 0); + + if ($orderId === '') { + throw new BadRequestException('订单号不能为空'); + } + if ($refundAmount <= 0) { + throw new BadRequestException('退款金额必须大于0'); + } + + // 1. 查询订单 + $order = $this->orderRepository->findByOrderId($orderId); + if (!$order) { + throw new NotFoundException('订单不存在'); + } + + // 2. 验证订单状态 + if ($order->status !== PaymentOrder::STATUS_SUCCESS) { + throw new BadRequestException('订单状态不允许退款'); + } + + // 3. 验证退款金额 + if ($refundAmount > $order->amount) { + throw new BadRequestException('退款金额不能大于订单金额'); + } + + // 4. 查询通道 + $channel = $this->channelRepository->find($order->channel_id); + if (!$channel) { + throw new NotFoundException('支付通道不存在'); + } + + // 5. 查询支付方式 + $method = $this->methodRepository->find($order->method_id); + if (!$method) { + throw new NotFoundException('支付方式不存在'); + } + + // 6. 实例化插件并初始化(通过插件服务) + $plugin = $this->pluginService->getPluginInstance($channel->plugin_code); + + $channelConfig = array_merge( + $channel->getConfigArray(), + ['enabled_products' => $channel->getEnabledProducts()] + ); + $plugin->init($method->method_code, $channelConfig); + + // 7. 调用插件退款 + $refundData = [ + 'order_id' => $order->order_id, + 'chan_order_no' => $order->chan_order_no, + 'chan_trade_no' => $order->chan_trade_no, + 'refund_amount' => $refundAmount, + 'refund_reason' => $data['refund_reason'] ?? '', + ]; + + $refundResult = $plugin->refund($refundData, $channelConfig); + + // 8. 如果是全额退款则关闭订单 + if ($refundAmount >= $order->amount) { + $this->orderRepository->updateById($order->id, [ + 'status' => PaymentOrder::STATUS_CLOSED, + 'extra' => array_merge($order->extra ?? [], [ + 'refund_info' => $refundResult, + ]), + ]); + } + + return [ + 'order_id' => $order->order_id, + 'refund_amount' => $refundAmount, + 'refund_result' => $refundResult, + ]; + } + + /** + * 生成支付订单号 + */ + private function generateOrderId(): string + { + return 'P' . date('YmdHis') . mt_rand(100000, 999999); + } +} diff --git a/app/services/PayService.php b/app/services/PayService.php new file mode 100644 index 0000000..9f74a43 --- /dev/null +++ b/app/services/PayService.php @@ -0,0 +1,161 @@ +payOrderService->createOrder($orderData); + + // 2. 查询支付方式 + $method = $this->methodRepository->find($order->method_id); + if (!$method) { + throw new NotFoundException('支付方式不存在'); + } + + // 3. 通道路由 + $channel = $this->channelRouterService->chooseChannel( + $order->merchant_id, + $order->merchant_app_id, + $order->method_id + ); + + // 4. 实例化插件并初始化(通过插件服务) + $plugin = $this->pluginService->getPluginInstance($channel->plugin_code); + + $channelConfig = array_merge( + $channel->getConfigArray(), + ['enabled_products' => $channel->getEnabledProducts()] + ); + $plugin->init($method->method_code, $channelConfig); + + // 5. 环境检测 + $device = $options['device'] ?? ''; + /** @var Request|null $request */ + $request = $options['request'] ?? null; + + if ($device) { + $env = $this->mapDeviceToEnv($device); + } elseif ($request instanceof Request) { + $env = $this->detectEnvironment($request); + } else { + $env = AbstractPayPlugin::ENV_PC; + } + + // 6. 调用插件统一下单 + $pluginOrderData = [ + 'order_id' => $order->order_id, + 'mch_no' => $order->mch_order_no, + 'amount' => $order->amount, + 'subject' => $order->subject, + 'body' => $order->body, + ]; + + $payResult = $plugin->unifiedOrder($pluginOrderData, $channelConfig, $env); + + // 7. 计算实际支付金额(扣除手续费) + $fee = $order->fee > 0 ? $order->fee : ($order->amount * ($channel->chan_cost / 100)); + $realAmount = $order->amount - $fee; + + // 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'] ?? ''; + + $this->orderRepository->updateById($order->id, [ + 'channel_id' => $channel->id, + 'chan_order_no' => $chanOrderNo, + 'chan_trade_no' => $chanTradeNo, + 'real_amount' => $realAmount, + 'fee' => $fee, + 'extra' => $extra, + ]); + + return [ + 'order_id' => $order->order_id, + 'mch_no' => $order->mch_order_no, + 'pay_params' => $payResult['pay_params'] ?? null, + ]; + } + + /** + * 根据请求 UA 检测环境 + */ + private function detectEnvironment(Request $request): string + { + $ua = strtolower($request->header('User-Agent', '')); + + if (strpos($ua, 'alipayclient') !== false) { + return AbstractPayPlugin::ENV_ALIPAY_CLIENT; + } + + if (strpos($ua, 'micromessenger') !== false) { + return AbstractPayPlugin::ENV_WECHAT; + } + + $mobileKeywords = ['mobile', 'android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone']; + foreach ($mobileKeywords as $keyword) { + if (strpos($ua, $keyword) !== false) { + return AbstractPayPlugin::ENV_H5; + } + } + + return AbstractPayPlugin::ENV_PC; + } + + /** + * 映射设备类型到环境代码 + */ + private function mapDeviceToEnv(string $device): string + { + $mapping = [ + 'pc' => AbstractPayPlugin::ENV_PC, + 'mobile' => AbstractPayPlugin::ENV_H5, + 'qq' => AbstractPayPlugin::ENV_H5, + 'wechat' => AbstractPayPlugin::ENV_WECHAT, + 'alipay' => AbstractPayPlugin::ENV_ALIPAY_CLIENT, + 'jump' => AbstractPayPlugin::ENV_PC, + ]; + + return $mapping[strtolower($device)] ?? AbstractPayPlugin::ENV_PC; + } +} + + diff --git a/app/services/PluginService.php b/app/services/PluginService.php new file mode 100644 index 0000000..023211e --- /dev/null +++ b/app/services/PluginService.php @@ -0,0 +1,122 @@ + + */ + public function listPlugins(): array + { + $rows = $this->pluginRepository->getActivePlugins(); + + $plugins = []; + foreach ($rows as $row) { + $pluginCode = $row->plugin_code; + + try { + $plugin = $this->resolvePlugin($pluginCode, $row->class_name); + $plugins[] = [ + 'code' => $pluginCode, + 'name' => $plugin::getName(), + 'supported_methods'=> $plugin::getSupportedMethods(), + ]; + } catch (\Throwable $e) { + // 忽略无法实例化的插件 + continue; + } + } + + return $plugins; + } + + /** + * 获取插件配置 Schema + */ + public function getConfigSchema(string $pluginCode, string $methodCode): array + { + $plugin = $this->getPluginInstance($pluginCode); + return $plugin::getConfigSchema($methodCode); + } + + /** + * 获取插件支持的支付产品列表 + */ + public function getSupportedProducts(string $pluginCode, string $methodCode): array + { + $plugin = $this->getPluginInstance($pluginCode); + return $plugin::getSupportedProducts($methodCode); + } + + /** + * 从表单数据中提取插件配置参数(根据插件 Schema) + */ + public function buildConfigFromForm(string $pluginCode, string $methodCode, array $formData): array + { + $plugin = $this->getPluginInstance($pluginCode); + $configSchema = $plugin::getConfigSchema($methodCode); + + $configJson = []; + if (isset($configSchema['fields']) && is_array($configSchema['fields'])) { + foreach ($configSchema['fields'] as $field) { + $fieldName = $field['field'] ?? ''; + if ($fieldName && array_key_exists($fieldName, $formData)) { + $configJson[$fieldName] = $formData[$fieldName]; + } + } + } + + return $configJson; + } + + /** + * 对外统一提供:根据插件编码获取插件实例 + */ + public function getPluginInstance(string $pluginCode): AbstractPayPlugin + { + $row = $this->pluginRepository->findActiveByCode($pluginCode); + if (!$row) { + throw new NotFoundException('支付插件未注册或已禁用:' . $pluginCode); + } + + return $this->resolvePlugin($pluginCode, $row->class_name); + } + + /** + * 根据插件编码和 class_name 解析并实例化插件 + */ + private function resolvePlugin(string $pluginCode, ?string $className = null): AbstractPayPlugin + { + $class = $className ?: 'app\\common\\payment\\' . ucfirst($pluginCode) . 'Payment'; + + if (!class_exists($class)) { + throw new NotFoundException('支付插件类不存在:' . $class); + } + + $plugin = new $class(); + if (!$plugin instanceof AbstractPayPlugin) { + throw new NotFoundException('支付插件类型错误:' . $class); + } + + return $plugin; + } +} + diff --git a/app/services/UserService.php b/app/services/UserService.php deleted file mode 100644 index 23be6ea..0000000 --- a/app/services/UserService.php +++ /dev/null @@ -1,44 +0,0 @@ -users->find($id); - if (!$user) { - throw new NotFoundException('用户不存在'); - } - - $userArray = $user->toArray(); - - return [ - 'user' => $userArray, - 'roles' => ['admin'], - 'permissions' => ['*:*:*'], - ]; - } -} diff --git a/app/services/api/EpayService.php b/app/services/api/EpayService.php new file mode 100644 index 0000000..7644d7c --- /dev/null +++ b/app/services/api/EpayService.php @@ -0,0 +1,288 @@ +createUnifiedOrder($data, $request); + } + + /** + * API 接口支付(mapi.php) + * + * @param array $data + * @param Request $request + * @return array 符合易支付文档的返回结构 + */ + public function mapi(array $data, Request $request): array + { + $result = $this->createUnifiedOrder($data, $request); + $payParams = $result['pay_params'] ?? []; + + $response = [ + 'code' => 1, + 'msg' => 'success', + 'trade_no' => $result['order_id'], + ]; + + if (!empty($payParams['type'])) { + switch ($payParams['type']) { + case 'redirect': + $response['payurl'] = $payParams['url'] ?? ''; + break; + case 'qrcode': + $response['qrcode'] = $payParams['qrcode_url'] ?? $payParams['qrcode_data'] ?? ''; + break; + case 'jsapi': + if (!empty($payParams['urlscheme'])) { + $response['urlscheme'] = $payParams['urlscheme']; + } + break; + default: + // 不识别的类型不返回额外字段 + break; + } + } + + return $response; + } + + /** + * API 接口(api.php)- 处理 act=order / refund 等 + * + * @param array $data + * @return array + */ + public function api(array $data): array + { + $act = strtolower($data['act'] ?? ''); + + return match ($act) { + 'order' => $this->apiOrder($data), + 'refund' => $this->apiRefund($data), + default => [ + 'code' => 0, + 'msg' => '不支持的操作类型', + ], + }; + } + + /** + * api.php?act=order 查询单个订单 + */ + private function apiOrder(array $data): array + { + $pid = (int)($data['pid'] ?? 0); + $key = (string)($data['key'] ?? ''); + + if ($pid <= 0 || $key === '') { + throw new BadRequestException('商户参数错误'); + } + + $app = $this->merchantAppRepository->findByAppId((string)$pid); + if (!$app || $app->app_secret !== $key) { + throw new NotFoundException('商户不存在或密钥错误'); + } + + $tradeNo = $data['trade_no'] ?? ''; + $outTradeNo = $data['out_trade_no'] ?? ''; + + if ($tradeNo === '' && $outTradeNo === '') { + throw new BadRequestException('系统订单号与商户订单号不能同时为空'); + } + + if ($tradeNo !== '') { + $order = $this->orderRepository->findByOrderId($tradeNo); + } else { + $order = $this->orderRepository->findByMchNo($app->merchant_id, $app->id, $outTradeNo); + } + + if (!$order) { + throw new NotFoundException('订单不存在'); + } + + $methodCode = $this->getMethodCodeByOrder($order); + + return [ + 'code' => 1, + 'msg' => '查询订单号成功!', + 'trade_no' => $order->order_id, + 'out_trade_no' => $order->mch_order_no, + 'api_trade_no' => $order->chan_trade_no ?? '', + 'type' => $this->mapMethodToEpayType($methodCode), + 'pid' => (int)$pid, + 'addtime' => $order->created_at, + 'endtime' => $order->pay_at, + 'name' => $order->subject, + 'money' => (string)$order->amount, + 'status' => $order->status === PaymentOrder::STATUS_SUCCESS ? 1 : 0, + 'param' => $order->extra['param'] ?? '', + 'buyer' => '', + ]; + } + + /** + * api.php?act=refund 提交订单退款 + */ + private function apiRefund(array $data): array + { + $pid = (int)($data['pid'] ?? 0); + $key = (string)($data['key'] ?? ''); + + if ($pid <= 0 || $key === '') { + throw new BadRequestException('商户参数错误'); + } + + $app = $this->merchantAppRepository->findByAppId((string)$pid); + if (!$app || $app->app_secret !== $key) { + throw new NotFoundException('商户不存在或密钥错误'); + } + + $tradeNo = $data['trade_no'] ?? ''; + $outTradeNo = $data['out_trade_no'] ?? ''; + $money = (float)($data['money'] ?? 0); + + if ($tradeNo === '' && $outTradeNo === '') { + throw new BadRequestException('系统订单号与商户订单号不能同时为空'); + } + if ($money <= 0) { + throw new BadRequestException('退款金额必须大于0'); + } + + if ($tradeNo !== '') { + $order = $this->orderRepository->findByOrderId($tradeNo); + } else { + $order = $this->orderRepository->findByMchNo($app->merchant_id, $app->id, $outTradeNo); + } + + if (!$order) { + throw new NotFoundException('订单不存在'); + } + + $refundResult = $this->payOrderService->refundOrder([ + 'order_id' => $order->order_id, + 'refund_amount' => $money, + ]); + + return [ + 'code' => 0, + 'msg' => '退款成功', + ]; + } + + /** + * 创建订单并调用插件统一下单 + * + * @param array $data + * @param Request $request + * @return array + */ + private function createUnifiedOrder(array $data, Request $request): array + { + $pid = (int)($data['pid'] ?? 0); + if ($pid <= 0) { + throw new BadRequestException('商户ID不能为空'); + } + + // 根据 pid 映射应用(约定 pid = app_id) + $app = $this->merchantAppRepository->findByAppId((string)$pid); + if (!$app || $app->status !== 1) { + throw new NotFoundException('商户应用不存在或已禁用'); + } + + $methodCode = $this->mapEpayTypeToMethod($data['type'] ?? ''); + $orderData = [ + 'merchant_id' => $app->merchant_id, + 'app_id' => $app->id, + 'mch_order_no' => $data['out_trade_no'], + 'method_code' => $methodCode, + 'amount' => (float)$data['money'], + 'currency' => 'CNY', + 'subject' => $data['name'], + 'body' => $data['name'], + 'client_ip' => $data['clientip'] ?? $request->getRemoteIp(), + 'extra' => [ + 'param' => $data['param'] ?? '', + 'notify_url' => $data['notify_url'] ?? '', + 'return_url' => $data['return_url'] ?? '', + ], + ]; + + // 调用通用支付服务完成通道选择与插件下单 + return $this->payService->unifiedPay($orderData, [ + 'device' => $data['device'] ?? '', + 'request' => $request, + ]); + } + + /** + * 映射易支付 type 到内部 method_code + */ + private function mapEpayTypeToMethod(string $type): string + { + $mapping = [ + 'alipay' => 'alipay', + 'wxpay' => 'wechat', + 'qqpay' => 'qq', + ]; + + return $mapping[$type] ?? $type; + } + + /** + * 根据订单获取支付方式编码 + */ + private function getMethodCodeByOrder(PaymentOrder $order): string + { + $method = $this->methodRepository->find($order->method_id); + return $method ? $method->method_code : ''; + } + + /** + * 映射内部 method_code 到易支付 type + */ + private function mapMethodToEpayType(string $methodCode): string + { + $mapping = [ + 'alipay' => 'alipay', + 'wechat' => 'wxpay', + 'qq' => 'qqpay', + ]; + + return $mapping[$methodCode] ?? $methodCode; + } + +} diff --git a/app/validation/EpayValidator.php b/app/validation/EpayValidator.php new file mode 100644 index 0000000..7c504ba --- /dev/null +++ b/app/validation/EpayValidator.php @@ -0,0 +1,122 @@ + 'required|integer', + 'key' => 'sometimes|string', + + // 支付相关 + 'type' => 'sometimes|string', + 'out_trade_no' => 'required|string|max:64', + 'trade_no' => 'sometimes|string|max:64', + 'notify_url' => 'required|url|max:255', + 'return_url' => 'sometimes|url|max:255', + 'name' => 'required|string|max:127', + 'money' => 'required|numeric|min:0.01', + 'clientip' => 'sometimes|ip', + 'device' => 'sometimes|string|in:pc,mobile,qq,wechat,alipay,jump', + 'param' => 'sometimes|string|max:255', + + // 签名相关 + 'sign' => 'required|string|size:32', + 'sign_type' => 'required|string|in:MD5,md5', + + // API 动作 + 'act' => 'required|string', + 'limit' => 'sometimes|integer|min:1|max:50', + 'page' => 'sometimes|integer|min:1', + ]; + + protected array $messages = []; + + protected array $attributes = [ + 'pid' => '商户ID', + 'key' => '商户密钥', + 'type' => '支付方式', + 'out_trade_no' => '商户订单号', + 'trade_no' => '系统订单号', + 'notify_url' => '异步通知地址', + 'return_url' => '跳转通知地址', + 'name' => '商品名称', + 'money' => '商品金额', + 'clientip' => '用户IP地址', + 'device' => '设备类型', + 'param' => '业务扩展参数', + 'sign' => '签名字符串', + 'sign_type' => '签名类型', + 'act' => '操作类型', + 'limit' => '查询数量', + 'page' => '页码', + ]; + + /** + * 不同接口场景 + */ + protected array $scenes = [ + // 页面跳转支付 submit.php + 'submit' => [ + 'pid', + 'type', + 'out_trade_no', + 'notify_url', + 'return_url', + 'name', + 'money', + 'param', + 'sign', + 'sign_type', + ], + + // API 接口支付 mapi.php + 'mapi' => [ + 'pid', + 'type', + 'out_trade_no', + 'notify_url', + 'return_url', + 'name', + 'money', + 'clientip', + 'device', + 'param', + 'sign', + 'sign_type', + ], + + // api.php?act=order 查询单个订单 + 'api_order' => [ + 'act', + 'pid', + 'key', + // trade_no 与 out_trade_no 至少一个,由业务层进一步校验 + ], + + // api.php?act=refund 提交退款 + 'api_refund' => [ + 'act', + 'pid', + 'key', + 'money', + // trade_no/out_trade_no 至少一个 + ], + ]; +} + + diff --git a/config/process.php b/config/process.php index 892dc82..cf9cf46 100644 --- a/config/process.php +++ b/config/process.php @@ -46,7 +46,7 @@ return [ config_path(), base_path() . '/process', base_path() . '/support', - base_path() . '/resource', + // base_path() . '/resource', base_path() . '/.env', ], glob(base_path() . '/plugin/*/app'), glob(base_path() . '/plugin/*/config'), glob(base_path() . '/plugin/*/api')), // Files with these suffixes will be monitored diff --git a/database/ma_system_config.sql b/database/ma_system_config.sql deleted file mode 100644 index 29ed468..0000000 --- a/database/ma_system_config.sql +++ /dev/null @@ -1,11 +0,0 @@ --- 系统配置表 -CREATE TABLE IF NOT EXISTS `ma_system_config` ( - `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `config_key` varchar(100) NOT NULL DEFAULT '' COMMENT '配置项键名(唯一标识,直接使用字段名)', - `config_value` text COMMENT '配置项值(支持字符串、数字、JSON等)', - `created_at` datetime DEFAULT NULL COMMENT '创建时间', - `updated_at` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_config_key` (`config_key`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表'; - diff --git a/database/mvp_payment_tables.sql b/database/mvp_payment_tables.sql new file mode 100644 index 0000000..9f450a7 --- /dev/null +++ b/database/mvp_payment_tables.sql @@ -0,0 +1,248 @@ +-- ============================================ +-- 支付系统核心表结构(优化版) +-- ============================================ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ======================= +-- 1. 商户表 +-- ======================= +DROP TABLE IF EXISTS `ma_merchant`; +CREATE TABLE `ma_merchant` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `merchant_no` varchar(32) NOT NULL DEFAULT '' COMMENT '商户号(唯一,对外标识)', + `merchant_name` varchar(100) NOT NULL DEFAULT '' COMMENT '商户名称', + `funds_mode` varchar(20) NOT NULL DEFAULT 'direct' COMMENT '资金模式:direct-直连, wallet-归集, hybrid-混合', + `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_merchant_no` (`merchant_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户表'; + +-- ======================= +-- 2. 商户应用表 +-- ======================= +DROP TABLE IF EXISTS `ma_merchant_app`; +CREATE TABLE `ma_merchant_app` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', + `api_type` varchar(32) NOT NULL DEFAULT 'default' COMMENT '接口类型:openapi, epay, custom 等', + `app_id` varchar(64) NOT NULL DEFAULT '' COMMENT '应用ID', + `app_secret` varchar(128) NOT NULL DEFAULT '' COMMENT '应用密钥', + `app_name` varchar(100) NOT NULL DEFAULT '' COMMENT '应用名称', + `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_app_id` (`app_id`), + KEY `idx_merchant_id` (`merchant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户应用表'; + +-- ======================= +-- 3. 支付方式字典表 +-- ======================= +DROP TABLE IF EXISTS `ma_pay_method`; +CREATE TABLE `ma_pay_method` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `method_code` varchar(32) NOT NULL DEFAULT '' COMMENT '支付方式编码,如 alipay,wechat', + `method_name` varchar(50) NOT NULL DEFAULT '' COMMENT '支付方式名称', + `icon` varchar(255) NOT NULL DEFAULT '' COMMENT '图标', + `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序', + `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_method_code` (`method_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付方式字典表'; + +-- ======================= +-- 4. 支付插件注册表 +-- ======================= +DROP TABLE IF EXISTS `ma_pay_plugin`; +CREATE TABLE `ma_pay_plugin` ( + `plugin_code` varchar(32) NOT NULL DEFAULT '' COMMENT '插件编码(主键)', + `plugin_name` varchar(50) NOT NULL DEFAULT '' COMMENT '插件名称', + `class_name` varchar(255) NOT NULL DEFAULT '' COMMENT '插件类名(完整命名空间)', + `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`plugin_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付插件注册表'; + +-- ======================= +-- 5. 支付通道表 +-- ======================= +DROP TABLE IF EXISTS `ma_pay_channel`; +CREATE TABLE `ma_pay_channel` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID(冗余,方便统计)', + `merchant_app_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户应用ID(关联 ma_merchant_app.id)', + `chan_code` varchar(32) NOT NULL DEFAULT '' COMMENT '通道编码(唯一)', + `chan_name` varchar(100) NOT NULL DEFAULT '' COMMENT '通道显示名称', + `plugin_code` varchar(32) NOT NULL DEFAULT '' COMMENT '支付插件编码', + `method_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付方式ID(关联 ma_pay_method.id)', + `config_json` json DEFAULT NULL COMMENT '通道插件配置参数(JSON,对应插件配置,包括 enabled_products 等)', + `split_ratio` decimal(5,2) NOT NULL DEFAULT 100.00 COMMENT '分成比例(%)', + `chan_cost` decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT '通道成本(%)', + `chan_mode` varchar(50) NOT NULL DEFAULT 'wallet' COMMENT '通道模式:wallet-入余额, direct-直连到商户', + `daily_limit` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '单日限额(元,0表示不限制)', + `daily_cnt` int(11) NOT NULL DEFAULT 0 COMMENT '单日限笔(0表示不限制)', + `min_amount` decimal(12,2) DEFAULT NULL COMMENT '单笔最小金额(元,NULL表示不限制)', + `max_amount` decimal(12,2) DEFAULT NULL COMMENT '单笔最大金额(元,NULL表示不限制)', + `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', + `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序,越小优先级越高', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_chan_code` (`chan_code`), + KEY `idx_mch_app_method` (`merchant_id`,`merchant_app_id`,`method_id`,`status`,`sort`), + KEY `idx_plugin_method` (`plugin_code`,`method_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付通道表'; + +-- ======================= +-- 6. 支付订单表 +-- ======================= +DROP TABLE IF EXISTS `ma_pay_order`; +CREATE TABLE `ma_pay_order` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `order_id` varchar(32) NOT NULL DEFAULT '' COMMENT '支付订单号(系统生成,唯一)', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', + `merchant_app_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户应用ID', + `mch_order_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户订单号(幂等)', + `method_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付方式ID(关联 ma_pay_method.id)', + `channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付通道ID(关联 ma_pay_channel.id)', + `amount` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额(元)', + `real_amount` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '实际支付金额(元,扣除手续费后)', + `fee` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '手续费(元,可选,用于对账)', + `currency` varchar(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,如 CNY', + `subject` varchar(255) NOT NULL DEFAULT '' COMMENT '订单标题', + `body` varchar(500) NOT NULL DEFAULT '' COMMENT '订单描述', + `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '订单状态:0-PENDING,1-SUCCESS,2-FAIL,3-CLOSED', + `chan_order_no` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道订单号(渠道返回)', + `chan_trade_no` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道交易号(部分渠道有)', + `pay_at` datetime DEFAULT NULL COMMENT '支付时间', + `expire_at` datetime DEFAULT NULL COMMENT '订单过期时间', + `client_ip` varchar(50) NOT NULL DEFAULT '' COMMENT '客户端IP', + `notify_stat` tinyint(1) NOT NULL DEFAULT 0 COMMENT '商户通知状态:0-未通知,1-已通知成功', + `notify_cnt` int(11) NOT NULL DEFAULT 0 COMMENT '通知次数', + `extra` json DEFAULT NULL COMMENT '扩展字段(JSON,存储支付参数、退款信息等)', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_id` (`order_id`), + UNIQUE KEY `uk_mch_order` (`merchant_id`,`merchant_app_id`,`mch_order_no`), + KEY `idx_mch_app_created` (`merchant_id`,`merchant_app_id`,`created_at`), + KEY `idx_method_id` (`method_id`), + KEY `idx_channel_id` (`channel_id`), + KEY `idx_status_created` (`status`,`created_at`), + KEY `idx_pay_at` (`pay_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表'; + +-- ======================= +-- 7. 支付回调日志表 +-- ======================= +DROP TABLE IF EXISTS `ma_pay_callback_log`; +CREATE TABLE `ma_pay_callback_log` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `order_id` varchar(32) NOT NULL DEFAULT '' COMMENT '支付订单号(系统订单号)', + `channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '通道ID(关联 ma_pay_channel.id)', + `callback_type` varchar(20) NOT NULL DEFAULT '' COMMENT '回调类型:notify-异步通知, return-同步返回', + `request_data` text COMMENT '请求原始数据(完整回调参数)', + `verify_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '验签状态:0-失败,1-成功', + `process_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '处理状态:0-未处理,1-已处理', + `process_result` text COMMENT '处理结果(JSON或文本)', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_order_created` (`order_id`,`created_at`), + KEY `idx_channel_created` (`channel_id`,`created_at`), + KEY `idx_callback_type` (`callback_type`), + KEY `idx_verify_status` (`verify_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付回调日志表'; + +-- ======================= +-- 8. 商户通知任务表 +-- ======================= +DROP TABLE IF EXISTS `ma_notify_task`; +CREATE TABLE `ma_notify_task` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `order_id` varchar(32) NOT NULL DEFAULT '' COMMENT '支付订单号(系统订单号)', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', + `merchant_app_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户应用ID', + `notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '通知地址', + `notify_data` text COMMENT '通知数据(JSON格式)', + `status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING-待通知,SUCCESS-成功,FAIL-失败', + `retry_cnt` int(11) NOT NULL DEFAULT 0 COMMENT '重试次数', + `next_retry_at` datetime DEFAULT NULL COMMENT '下次重试时间', + `last_notify_at` datetime DEFAULT NULL COMMENT '最后通知时间', + `last_response` text COMMENT '最后响应内容', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_id` (`order_id`), + KEY `idx_status_retry` (`status`,`next_retry_at`), + KEY `idx_mch_app` (`merchant_id`,`merchant_app_id`), + KEY `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户通知任务表'; + +-- ======================= +-- 9. 系统配置表 +-- ======================= +DROP TABLE IF EXISTS `ma_system_config`; +CREATE TABLE IF NOT EXISTS `ma_system_config` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `config_key` varchar(100) NOT NULL DEFAULT '' COMMENT '配置项键名(唯一标识,直接使用字段名)', + `config_value` text COMMENT '配置项值(支持字符串、数字、JSON等)', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_config_key` (`config_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表'; + +-- ======================= +-- 10. 初始化基础数据 +-- ======================= + +-- 初始化支付方式字典 +INSERT INTO `ma_pay_method` (`method_code`, `method_name`, `icon`, `sort`, `status`) VALUES +('alipay', '支付宝', '', 1, 1), +('wechat', '微信支付', '', 2, 1), +('unionpay','云闪付', '', 3, 1) +ON DUPLICATE KEY UPDATE + `method_name` = VALUES(`method_name`), + `icon` = VALUES(`icon`), + `sort` = VALUES(`sort`), + `status` = VALUES(`status`); + +-- ======================= +-- 11. 管理员用户表(ma_admin) +-- ======================= + +DROP TABLE IF EXISTS `ma_admin`; +CREATE TABLE `ma_admin` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_name` varchar(50) NOT NULL DEFAULT '' COMMENT '用户名(登录账号,唯一)', + `password` varchar(255) DEFAULT NULL COMMENT '登录密码hash(NULL 或空表示使用默认开发密码)', + `nick_name` varchar(50) NOT NULL DEFAULT '' COMMENT '昵称', + `avatar` varchar(255) NOT NULL DEFAULT '' COMMENT '头像地址', + `mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '手机号', + `email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱', + `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', + `login_ip` varchar(45) NOT NULL DEFAULT '' COMMENT '最后登录IP', + `login_at` datetime DEFAULT NULL COMMENT '最后登录时间', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_name` (`user_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员用户表'; + +-- 初始化一个超级管理员账号(开发环境默认密码 123456,对应 AuthService::validatePassword 逻辑) +INSERT INTO `ma_admin` (`user_name`, `password`, `nick_name`, `status`, `created_at`) +VALUES ('admin', NULL, '超级管理员', 1, NOW()) +ON DUPLICATE KEY UPDATE + `nick_name` = VALUES(`nick_name`), + `status` = VALUES(`status`); + +SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/doc/auth_strategy_design.md b/doc/auth_strategy_design.md new file mode 100644 index 0000000..596cce2 --- /dev/null +++ b/doc/auth_strategy_design.md @@ -0,0 +1,145 @@ +# 认证策略设计说明 + +## 设计理念 + +采用**策略模式**替代中间件方式处理认证,具有以下优势: + +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/epay.md b/doc/epay.md new file mode 100644 index 0000000..8ece215 --- /dev/null +++ b/doc/epay.md @@ -0,0 +1,216 @@ +协议规则 +请求数据格式:application/x-www-form-urlencoded + +返回数据格式:JSON + +签名算法:MD5 + +字符编码:UTF-8 + +页面跳转支付 +此接口可用于用户前台直接发起支付,使用form表单跳转或拼接成url跳转。 + +URL地址:http://192.168.31.200:4000/submit.php + +请求方式:POST 或 GET(推荐POST,不容易被劫持或屏蔽) + +请求参数说明: + +字段名 变量名 必填 类型 示例值 描述 +商户ID pid 是 Int 1001 +支付方式 type 否 String alipay 支付方式列表 +商户订单号 out_trade_no 是 String 20160806151343349 +异步通知地址 notify_url 是 String http://www.pay.com/notify_url.php 服务器异步通知地址 +跳转通知地址 return_url 是 String http://www.pay.com/return_url.php 页面跳转通知地址 +商品名称 name 是 String VIP会员 如超过127个字节会自动截取 +商品金额 money 是 String 1.00 单位:元,最大2位小数 +业务扩展参数 param 否 String 没有请留空 支付后原样返回 +签名字符串 sign 是 String 202cb962ac59075b964b07152d234b70 签名算法点此查看 +签名类型 sign_type 是 String MD5 默认为MD5 +支付方式(type)不传会跳转到收银台支付 + +API接口支付 +此接口可用于服务器后端发起支付请求,会返回支付二维码链接或支付跳转url。 + +URL地址:http://192.168.31.200:4000/mapi.php + +请求方式:POST + +请求参数说明: + +字段名 变量名 必填 类型 示例值 描述 +商户ID pid 是 Int 1001 +支付方式 type 是 String alipay 支付方式列表 +商户订单号 out_trade_no 是 String 20160806151343349 +异步通知地址 notify_url 是 String http://www.pay.com/notify_url.php 服务器异步通知地址 +跳转通知地址 return_url 否 String http://www.pay.com/return_url.php 页面跳转通知地址 +商品名称 name 是 String VIP会员 如超过127个字节会自动截取 +商品金额 money 是 String 1.00 单位:元,最大2位小数 +用户IP地址 clientip 是 String 192.168.1.100 用户发起支付的IP地址 +设备类型 device 否 String pc 根据用户浏览器的UA判断, +传入用户所使用的浏览器 +或设备类型,默认为pc +设备类型列表 +业务扩展参数 param 否 String 没有请留空 支付后原样返回 +签名字符串 sign 是 String 202cb962ac59075b964b07152d234b70 签名算法点此查看 +签名类型 sign_type 是 String MD5 默认为MD5 +返回结果(json): + +字段名 变量名 类型 示例值 描述 +返回状态码 code Int 1 1为成功,其它值为失败 +返回信息 msg String 失败时返回原因 +订单号 trade_no String 20160806151343349 支付订单号 +支付跳转url payurl String http://192.168.31.200:4000/pay/wxpay/202010903/ 如果返回该字段,则直接跳转到该url支付 +二维码链接 qrcode String weixin://wxpay/bizpayurl?pr=04IPMKM 如果返回该字段,则根据该url生成二维码 +小程序跳转url urlscheme String weixin://dl/business/?ticket=xxx 如果返回该字段,则使用js跳转该url,可发起微信小程序支付 +注:payurl、qrcode、urlscheme 三个参数只会返回其中一个 + +支付结果通知 +通知类型:服务器异步通知(notify_url)、页面跳转通知(return_url) + +请求方式:GET + +请求参数说明: + +字段名 变量名 必填 类型 示例值 描述 +商户ID pid 是 Int 1001 +易支付订单号 trade_no 是 String 20160806151343349021 聚合支付平台订单号 +商户订单号 out_trade_no 是 String 20160806151343349 商户系统内部的订单号 +支付方式 type 是 String alipay 支付方式列表 +商品名称 name 是 String VIP会员 +商品金额 money 是 String 1.00 +支付状态 trade_status 是 String TRADE_SUCCESS 只有TRADE_SUCCESS是成功 +业务扩展参数 param 否 String +签名字符串 sign 是 String 202cb962ac59075b964b07152d234b70 签名算法点此查看 +签名类型 sign_type 是 String MD5 默认为MD5 +收到异步通知后,需返回success以表示服务器接收到了订单通知 + +MD5签名算法 +1、将发送或接收到的所有参数按照参数名ASCII码从小到大排序(a-z),sign、sign_type、和空值不参与签名! + +2、将排序后的参数拼接成URL键值对的格式,例如 a=b&c=d&e=f,参数值不要进行url编码。 + +3、再将拼接好的字符串与商户密钥KEY进行MD5加密得出sign签名参数,sign = md5 ( a=b&c=d&e=f + KEY ) (注意:+ 为各语言的拼接符,不是字符!),md5结果为小写。 + +4、具体签名与发起支付的示例代码可下载SDK查看。 + +支付方式列表 +调用值 描述 +alipay 支付宝 +wxpay 微信支付 +qqpay QQ钱包 +设备类型列表 +调用值 描述 +pc 电脑浏览器 +mobile 手机浏览器 +qq 手机QQ内浏览器 +wechat 微信内浏览器 +alipay 支付宝客户端 +jump 仅返回支付跳转url +[API]查询商户信息 +URL地址:http://192.168.31.200:4000/api.php?act=query&pid={商户ID}&key={商户密钥} + +请求参数说明: + +字段名 变量名 必填 类型 示例值 描述 +操作类型 act 是 String query 此API固定值 +商户ID pid 是 Int 1001 +商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i +返回结果: + +字段名 变量名 类型 示例值 描述 +返回状态码 code Int 1 1为成功,其它值为失败 +商户ID pid Int 1001 商户ID +商户密钥 key String(32) 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i 商户密钥 +商户状态 active Int 1 1为正常,0为封禁 +商户余额 money String 0.00 商户所拥有的余额 +结算方式 type Int 1 1:支付宝,2:微信,3:QQ,4:银行卡 +结算账号 account String admin@pay.com 结算的支付宝账号 +结算姓名 username String 张三 结算的支付宝姓名 +订单总数 orders Int 30 订单总数统计 +今日订单 order_today Int 15 今日订单数量 +昨日订单 order_lastday Int 15 昨日订单数量 +[API]查询结算记录 +URL地址:http://192.168.31.200:4000/api.php?act=settle&pid={商户ID}&key={商户密钥} + +请求参数说明: + +字段名 变量名 必填 类型 示例值 描述 +操作类型 act 是 String settle 此API固定值 +商户ID pid 是 Int 1001 +商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i +返回结果: + +字段名 变量名 类型 示例值 描述 +返回状态码 code Int 1 1为成功,其它值为失败 +返回信息 msg String 查询结算记录成功! +结算记录 data Array 结算记录列表 +[API]查询单个订单 +URL地址:http://192.168.31.200:4000/api.php?act=order&pid={商户ID}&key={商户密钥}&out_trade_no={商户订单号} + +请求参数说明: + +字段名 变量名 必填 类型 示例值 描述 +操作类型 act 是 String order 此API固定值 +商户ID pid 是 Int 1001 +商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i +系统订单号 trade_no 选择 String 20160806151343312 +商户订单号 out_trade_no 选择 String 20160806151343349 +提示:系统订单号 和 商户订单号 二选一传入即可,如果都传入以系统订单号为准! + +返回结果: + +字段名 变量名 类型 示例值 描述 +返回状态码 code Int 1 1为成功,其它值为失败 +返回信息 msg String 查询订单号成功! +易支付订单号 trade_no String 2016080622555342651 聚合支付平台订单号 +商户订单号 out_trade_no String 20160806151343349 商户系统内部的订单号 +第三方订单号 api_trade_no String 20160806151343349 支付宝微信等接口方订单号 +支付方式 type String alipay 支付方式列表 +商户ID pid Int 1001 发起支付的商户ID +创建订单时间 addtime String 2016-08-06 22:55:52 +完成交易时间 endtime String 2016-08-06 22:55:52 +商品名称 name String VIP会员 +商品金额 money String 1.00 +支付状态 status Int 0 1为支付成功,0为未支付 +业务扩展参数 param String 默认留空 +支付者账号 buyer String 默认留空 +[API]批量查询订单 +URL地址:http://192.168.31.200:4000/api.php?act=orders&pid={商户ID}&key={商户密钥} + +请求参数说明: + +字段名 变量名 必填 类型 示例值 描述 +操作类型 act 是 String orders 此API固定值 +商户ID pid 是 Int 1001 +商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i +查询订单数量 limit 否 Int 20 返回的订单数量,最大50 +页码 page 否 Int 1 当前查询的页码 +返回结果: + +字段名 变量名 类型 示例值 描述 +返回状态码 code Int 1 1为成功,其它值为失败 +返回信息 msg String 查询结算记录成功! +订单列表 data Array 订单列表 +[API]提交订单退款 +需要先在商户后台开启订单退款API接口开关,才能调用该接口发起订单退款 + +URL地址:http://192.168.31.200:4000/api.php?act=refund + +请求方式:POST + +请求参数说明: + +字段名 变量名 必填 类型 示例值 描述 +商户ID pid 是 Int 1001 +商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i +易支付订单号 trade_no 特殊可选 String 20160806151343349021 易支付订单号 +商户订单号 out_trade_no 特殊可选 String 20160806151343349 订单支付时传入的商户订单号,商家自定义且保证商家系统中唯一 +退款金额 money 是 String 1.50 少数通道需要与原订单金额一致 +注:trade_no、out_trade_no 不能同时为空,如果都传了以trade_no为准 + +返回结果: + +字段名 变量名 类型 示例值 描述 +返回状态码 code Int 0 0为成功,其它值为失败 +返回信息 msg String 退款成功 \ No newline at end of file diff --git a/doc/order_table_design.md b/doc/order_table_design.md new file mode 100644 index 0000000..17d24ac --- /dev/null +++ b/doc/order_table_design.md @@ -0,0 +1,214 @@ +# 支付订单表设计说明 + +## 一、订单表设计原因 + +### 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 new file mode 100644 index 0000000..d3538e6 --- /dev/null +++ b/doc/payment_flow.md @@ -0,0 +1,485 @@ +# 支付订单发起流程说明 + +## 一、业务系统调用统一下单接口 + +### 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 new file mode 100644 index 0000000..daa6ec9 --- /dev/null +++ b/doc/payment_system_implementation.md @@ -0,0 +1,182 @@ +# 支付系统核心实现说明 + +## 概述 + +已实现支付系统核心功能,包括: +- 插件化支付通道系统(支持一个插件多个支付方式) +- 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/skill.md b/doc/skill.md index f2f369a..d8f8699 100644 --- a/doc/skill.md +++ b/doc/skill.md @@ -2,7 +2,7 @@ ## 1. 项目概述 -MPAY V2 是一个基于 Webman 后端框架和 Vue 3 前端框架的支付管理系统,提供完整的支付业务管理功能,包括用户认证、菜单管理、系统配置、财务管理、渠道管理和数据分析等核心模块。 +MPAY V2 是一个基于 Webman 后端框架和 Vue 3 前端框架的支付管理系统,核心聚焦支付业务:商户管理、通道配置、统一支付、易支付兼容、商户通知等。管理后台提供管理员认证、菜单、系统配置、通道与插件管理;对外提供 OpenAPI 与易支付标准接口。 ## 2. 技术架构 @@ -10,46 +10,38 @@ MPAY V2 是一个基于 Webman 后端框架和 Vue 3 前端框架的支付管理 | 类别 | 技术/框架 | 版本 | 用途 | 来源 | |------|-----------|------|------|------| -| 基础框架 | Webman | ^2.1 | 高性能HTTP服务框架 | composer.json:28 | -| PHP版本 | PHP | >=8.1 | 开发语言 | composer.json:27 | -| 数据库 | webman/database | ^2.1 | 数据库操作 | composer.json:31 | -| 缓存 | Redis | ^2.1 | 缓存存储 | composer.json:32 | -| 缓存 | webman/cache | ^2.1 | 缓存管理 | composer.json:34 | -| 认证 | JWT | ^7.0 | 用户认证 | composer.json:42 | -| 验证码 | webman/captcha | ^1.0 | 登录验证码 | composer.json:37 | -| 事件系统 | webman/event | ^1.0 | 事件管理 | composer.json:38 | -| 配置管理 | vlucas/phpdotenv | ^5.6 | 环境变量 | composer.json:39 | -| 定时任务 | workerman/crontab | ^1.0 | 定时任务 | composer.json:40 | -| 队列 | webman/redis-queue | ^2.1 | 消息队列 | composer.json:41 | -| 验证 | topthink/think-validate | ^3.0 | 数据验证 | composer.json:36 | -| 容器 | php-di/php-di | 7.0 | 依赖注入 | composer.json:30 | -| 日志 | monolog/monolog | ^2.0 | 日志管理 | composer.json:29 | -| 控制台 | webman/console | ^2.1 | 命令行工具 | composer.json:35 | +| 基础框架 | Webman | ^2.1 | 高性能HTTP服务框架 | composer.json | +| PHP版本 | PHP | >=8.1 | 开发语言 | composer.json | +| 数据库 | webman/database | ^2.1 | 数据库操作 | composer.json | +| 缓存 | Redis | ^2.1 | 缓存存储 | composer.json | +| 缓存 | webman/cache | ^2.1 | 缓存管理 | composer.json | +| 认证 | JWT | ^7.0 | 管理员认证 | composer.json | +| 验证码 | webman/captcha | ^1.0 | 登录验证码 | composer.json | +| 事件系统 | webman/event | ^1.0 | 事件管理 | composer.json | +| 配置管理 | vlucas/phpdotenv | ^5.6 | 环境变量 | composer.json | +| 定时任务 | workerman/crontab | ^1.0 | 定时任务 | composer.json | +| 队列 | webman/redis-queue | ^2.1 | 消息队列 | composer.json | +| 验证 | topthink/think-validate | ^3.0 | 数据验证 | composer.json | +| 容器 | php-di/php-di | 7.0 | 依赖注入 | composer.json | +| 日志 | monolog/monolog | ^2.0 | 日志管理 | composer.json | +| 控制台 | webman/console | ^2.1 | 命令行工具 | composer.json | ### 2.2 前端技术栈 | 类别 | 技术/框架 | 版本 | 用途 | 来源 | |------|-----------|------|------|------| -| 基础框架 | Vue | ^3.5.15 | 前端框架 | package.json:61 | -| 语言 | TypeScript | ^5.2.2 | 开发语言 | package.json:103 | -| 构建工具 | Vite | ^6.3.5 | 构建工具 | package.json:107 | -| UI框架 | Arco Design | ^2.57.0 | 界面组件库 | package.json:72 | -| 状态管理 | Pinia | ^2.3.0 | 状态管理 | package.json:53 | -| 路由 | Vue Router | ^4.3.0 | 前端路由 | package.json:66 | -| HTTP客户端 | Axios | ^1.6.8 | API调用 | package.json:47 | -| 表单生成 | @form-create/arco-design | ^3.2.37 | 动态表单 | package.json:41 | -| 图表 | @visactor/vchart | ^1.11.0 | 数据可视化 | package.json:42 | -| 代码编辑器 | CodeMirror | ^6.0.1 | 代码编辑 | package.json:48 | -| 富文本编辑器 | @wangeditor/editor | ^5.1.23 | 内容编辑 | package.json:45 | -| 国际化 | vue-i18n | 10.0.0-alpha.3 | 多语言支持 | package.json:64 | -| 工具库 | @vueuse/core | ^12.4.0 | 实用工具 | package.json:44 | -| 指纹识别 | @fingerprintjs/fingerprintjs | ^4.6.2 | 设备识别 | package.json:40 | -| 二维码 | qrcode | ^1.5.4 | 二维码生成 | package.json:57 | -| 条码 | jsbarcode | ^3.11.6 | 条码生成 | package.json:51 | -| 打印 | print-js | ^1.6.0 | 页面打印 | package.json:56 | -| 进度条 | nprogress | ^0.2.0 | 加载进度 | package.json:52 | -| 中文转拼音 | pinyin-pro | ^3.26.0 | 拼音转换 | package.json:55 | -| 引导 | driver.js | ^1.3.1 | 功能引导 | package.json:49 | +| 基础框架 | Vue | ^3.5.15 | 前端框架 | package.json | +| 语言 | TypeScript | ^5.2.2 | 开发语言 | package.json | +| 构建工具 | Vite | ^6.3.5 | 构建工具 | package.json | +| UI框架 | Arco Design | ^2.57.0 | 界面组件库 | package.json | +| 状态管理 | Pinia | ^2.3.0 | 状态管理 | package.json | +| 路由 | Vue Router | ^4.3.0 | 前端路由 | package.json | +| HTTP客户端 | Axios | ^1.6.8 | API调用 | package.json | +| 表单生成 | @form-create/arco-design | ^3.2.37 | 动态表单 | package.json | +| 图表 | @visactor/vchart | ^1.11.0 | 数据可视化 | package.json | +| 国际化 | vue-i18n | 10.0.0-alpha.3 | 多语言支持 | package.json | +| 工具库 | @vueuse/core | ^12.4.0 | 实用工具 | package.json | +| 二维码 | qrcode | ^1.5.4 | 二维码生成 | package.json | ## 3. 项目结构 @@ -57,267 +49,264 @@ MPAY V2 是一个基于 Webman 后端框架和 Vue 3 前端框架的支付管理 ``` d:\phpstudy_pro\WWW\mpay\mpay_v2_webman\ -├── app/ # 应用代码 -│ ├── common/ # 通用代码 -│ │ ├── base/ # 基础类 +├── app/ # 应用代码 +│ ├── common/ # 通用代码 +│ │ ├── base/ # 基础类 │ │ │ ├── BaseController.php │ │ │ ├── BaseModel.php │ │ │ ├── BaseRepository.php │ │ │ └── BaseService.php -│ │ ├── constants/ # 常量 -│ │ │ └── YesNo.php -│ │ ├── enums/ # 枚举 -│ │ │ └── MenuType.php -│ │ ├── middleware/ # 中间件 -│ │ │ ├── Cors.php -│ │ │ └── StaticFile.php -│ │ └── utils/ # 工具类 -│ │ └── JwtUtil.php -│ ├── events/ # 事件 -│ │ └── SystemConfig.php -│ ├── exceptions/ # 异常处理 -│ │ └── ValidationException.php -│ ├── http/ # HTTP相关 -│ │ ├── admin/ # 后台管理 -│ │ │ ├── controller/ # 控制器 +│ │ ├── contracts/ # 契约/接口 +│ │ │ ├── PayPluginInterface.php +│ │ │ └── AbstractPayPlugin.php +│ │ ├── constants/ # 常量 +│ │ ├── enums/ # 枚举 +│ │ ├── middleware/ # 中间件(Cors, StaticFile) +│ │ ├── payment/ # 支付插件实现 +│ │ │ └── LakalaPayment.php +│ │ └── utils/ # 工具类(JwtUtil 等) +│ ├── events/ # 事件 +│ ├── exceptions/ # 异常(BadRequest, NotFound, Validation 等) +│ ├── http/ +│ │ ├── admin/ # 管理后台 +│ │ │ ├── controller/ │ │ │ │ ├── AuthController.php +│ │ │ │ ├── AdminController.php │ │ │ │ ├── MenuController.php │ │ │ │ ├── SystemController.php -│ │ │ │ └── UserController.php -│ │ │ └── middleware/ # 中间件 +│ │ │ │ ├── ChannelController.php +│ │ │ │ └── PluginController.php +│ │ │ └── middleware/ │ │ │ └── AuthMiddleware.php -│ ├── models/ # 数据模型 -│ │ ├── SystemConfig.php -│ │ └── User.php -│ ├── process/ # 进程管理 -│ │ ├── Http.php -│ │ └── Monitor.php -│ ├── repositories/ # 数据仓库 -│ │ ├── SystemConfigRepository.php -│ │ └── UserRepository.php -│ ├── routes/ # 路由配置 +│ │ └── api/ # 对外 API +│ │ ├── controller/ +│ │ │ ├── PayController.php # OpenAPI 支付接口(骨架) +│ │ │ └── EpayController.php # 易支付接口(submit.php/mapi.php/api.php) +│ │ └── middleware/ +│ │ ├── EpayAuthMiddleware.php +│ │ └── OpenApiAuthMiddleware.php +│ ├── jobs/ # 异步任务 +│ │ └── NotifyMerchantJob.php +│ ├── models/ # 数据模型 +│ │ ├── Admin.php +│ │ ├── Merchant.php +│ │ ├── MerchantApp.php +│ │ ├── PaymentMethod.php +│ │ ├── PaymentPlugin.php +│ │ ├── PaymentChannel.php +│ │ ├── PaymentOrder.php +│ │ ├── PaymentCallbackLog.php +│ │ ├── PaymentNotifyTask.php +│ │ └── SystemConfig.php +│ ├── repositories/ # 数据仓储 +│ │ ├── AdminRepository.php +│ │ ├── MerchantRepository.php +│ │ ├── MerchantAppRepository.php +│ │ ├── PaymentMethodRepository.php +│ │ ├── PaymentPluginRepository.php +│ │ ├── PaymentChannelRepository.php +│ │ ├── PaymentOrderRepository.php +│ │ ├── PaymentNotifyTaskRepository.php +│ │ ├── PaymentCallbackLogRepository.php +│ │ └── SystemConfigRepository.php +│ ├── routes/ # 路由 │ │ ├── admin.php │ │ ├── api.php │ │ └── mer.php -│ ├── services/ # 业务逻辑 +│ ├── services/ # 业务逻辑 │ │ ├── AuthService.php +│ │ ├── AdminService.php │ │ ├── CaptchaService.php │ │ ├── MenuService.php │ │ ├── SystemConfigService.php │ │ ├── SystemSettingService.php -│ │ └── UserService.php -│ └── validation/ # 数据验证 -│ └── SystemConfigValidator.php -├── config/ # 配置文件 -│ ├── base-config/ # 基础配置 -│ │ ├── basic.json -│ │ ├── email.json -│ │ ├── permission.json -│ │ └── tabs.json -│ ├── plugin/ # 插件配置 -│ │ ├── webman/ -│ │ │ ├── console/ -│ │ │ ├── event/ -│ │ │ ├── redis-queue/ -│ │ │ └── validation/ -│ ├── system-file/ # 系统文件 -│ │ ├── dict.json -│ │ ├── menu.json -│ │ └── menu.md -│ ├── app.php -│ ├── autoload.php -│ ├── bootstrap.php -│ ├── cache.php -│ ├── container.php -│ ├── database.php -│ ├── dependence.php -│ ├── event.php -│ ├── exception.php -│ ├── jwt.php -│ ├── log.php -│ ├── menu.php -│ ├── middleware.php -│ ├── process.php -│ ├── redis.php -│ ├── route.php -│ ├── server.php -│ ├── session.php -│ ├── static.php -│ ├── translation.php -│ └── view.php -├── database/ # 数据库文件 -│ └── ma_system_config.sql -├── doc/ # 文档 -│ ├── event.md -│ └── exception.md -├── public/ # 静态资源 -│ └── favicon.ico -├── resource/ # 资源文件 -│ └── mpay_v2_admin/ # 前端项目 -├── .env # 环境变量 -├── composer.json # PHP依赖 -└── composer.lock # 依赖锁定 +│ │ ├── PluginService.php # 插件注册与实例化 +│ │ ├── ChannelRouterService.php # 通道路由(按商户+应用+支付方式选通道) +│ │ ├── PayOrderService.php # 订单创建、幂等、退款 +│ │ ├── PayService.php # 统一下单、调用插件 +│ │ ├── NotifyService.php # 商户通知、重试 +│ │ └── api/ +│ │ └── EpayService.php # 易支付业务封装 +│ ├── validation/ # 验证器 +│ │ ├── EpayValidator.php +│ │ └── SystemConfigValidator.php +│ └── process/ # 进程(Http, Monitor) +├── config/ # 配置文件 +├── database/ # 数据库脚本 +│ └── mvp_payment_tables.sql # 支付系统核心表(ma_*) +├── doc/ # 文档 +│ ├── skill.md +│ ├── epay.md +│ ├── payment_flow.md +│ ├── validation.md +│ └── payment_system_implementation.md +├── public/ +├── resource/ +│ └── mpay_v2_admin/ # 前端项目 +├── .env +└── composer.json ``` -### 3.2 前端目录结构 +### 3.2 数据库表结构(`database/mvp_payment_tables.sql`) + +| 表名 | 说明 | +|------|------| +| ma_merchant | 商户表 | +| ma_merchant_app | 商户应用表(api_type 区分 openapi/epay/custom) | +| ma_pay_method | 支付方式字典(alipay/wechat/unionpay) | +| ma_pay_plugin | 支付插件注册表(plugin_code 为主键) | +| ma_pay_channel | 支付通道表(merchant_id, merchant_app_id, method_id 关联) | +| ma_pay_order | 支付订单表(status: 0-PENDING, 1-SUCCESS, 2-FAIL, 3-CLOSED) | +| ma_pay_callback_log | 支付回调日志表 | +| ma_notify_task | 商户通知任务表(order_id, retry_cnt, next_retry_at) | +| ma_system_config | 系统配置表 | +| ma_admin | 管理员表 | + +### 3.3 前端目录结构 ``` -d:\phpstudy_pro\WWW\mpay\mpay_v2_webman\resource\mpay_v2_admin\ -├── src/ # 源代码 -│ ├── api/ # API调用 -│ ├── assets/ # 静态资源 -│ ├── components/ # 组件 -│ ├── config/ # 配置 -│ ├── directives/ # 指令 -│ ├── hooks/ # 钩子 -│ ├── lang/ # 国际化 -│ ├── layout/ # 布局 -│ ├── mock/ # 模拟数据 -│ ├── router/ # 路由 -│ ├── store/ # 状态管理 -│ ├── style/ # 样式 -│ ├── typings/ # 类型定义 -│ ├── utils/ # 工具函数 -│ ├── views/ # 页面 -│ ├── App.vue # 根组件 -│ ├── auto-import.d.ts # 自动导入 -│ ├── components.d.ts # 组件声明 -│ ├── main.ts # 入口文件 -│ └── style.css # 全局样式 -├── build/ # 构建配置 -│ ├── optimize.ts -│ └── vite-plugin.ts -├── .env # 环境变量 -├── .env.development # 开发环境变量 -├── .env.production # 生产环境变量 -├── .env.test # 测试环境变量 -├── eslint.config.js # ESLint配置 -├── index.html # HTML模板 -├── package.json # 前端依赖 -└── vite.config.ts # Vite配置 +resource/mpay_v2_admin/ +├── src/ +│ ├── api/ +│ ├── components/ +│ ├── layout/ +│ ├── router/ +│ ├── store/ +│ ├── views/ +│ │ ├── login/ +│ │ ├── home/ +│ │ ├── finance/ +│ │ ├── channel/ +│ │ ├── analysis/ +│ │ └── system/ +│ ├── App.vue +│ └── main.ts +├── package.json +└── vite.config.ts ``` ## 4. 核心功能模块 -### 4.1 后端核心模块 +### 4.1 支付业务流程约定 -| 模块 | 主要功能 | 文件位置 | 来源 | -|------|----------|----------|------| -| 认证模块 | 用户登录、验证码生成 | app/http/admin/controller/AuthController.php | app/routes/admin.php:20-21 | -| 用户模块 | 获取用户信息 | app/http/admin/controller/UserController.php | app/routes/admin.php:26 | -| 菜单模块 | 获取路由菜单 | app/http/admin/controller/MenuController.php | app/routes/admin.php:29 | -| 系统模块 | 字典管理、配置管理 | app/http/admin/controller/SystemController.php | app/routes/admin.php:32-37 | +1. **订单创建**:`PayOrderService::createOrder`,支持幂等(merchant_id + merchant_app_id + mch_order_no 唯一) +2. **通道路由**:`ChannelRouterService::chooseChannel(merchantId, merchantAppId, methodId)` 按第一个可用通道 +3. **统一下单**:`PayService::unifiedPay` → 创建订单 → 选通道 → 实例化插件 → 调用 `unifiedOrder` +4. **商户通知**:`NotifyService::createNotifyTask`,`notify_url` 从订单 `extra['notify_url']` 获取 +5. **通知重试**:`NotifyMerchantJob` 定时拉取待重试任务,指数退避 -### 4.2 前端核心模块 +### 4.2 支付插件接口 -| 模块 | 主要功能 | 文件位置 | 来源 | -|------|----------|----------|------| -| 布局模块 | 系统整体布局 | src/layout/ | resource/mpay_v2_admin/src/layout/ | -| 认证模块 | 登录、权限控制 | src/views/login/ | resource/mpay_v2_admin/src/views/ | -| 首页模块 | 数据概览 | src/views/home/ | resource/mpay_v2_admin/src/views/home/ | -| 财务管理 | 结算、对账、发票 | src/views/finance/ | resource/mpay_v2_admin/src/views/finance/ | -| 渠道管理 | 通道配置、支付方式 | src/views/channel/ | resource/mpay_v2_admin/src/views/channel/ | -| 数据分析 | 交易分析、商户分析 | src/views/analysis/ | resource/mpay_v2_admin/src/views/analysis/ | -| 系统设置 | 系统配置、字典管理 | src/views/system/ | resource/mpay_v2_admin/src/views/ | +- `app/common/contracts/PayPluginInterface.php` +- `app/common/contracts/AbstractPayPlugin.php` +- 示例实现:`app/common/payment/LakalaPayment.php` -## 5. API接口设计 +插件需实现:`getName`、`getSupportedMethods`、`getConfigSchema`、`getSupportedProducts`、`init`、`unifiedOrder`、`refund`、`verifyNotify` 等。 -### 5.1 认证接口 +### 4.3 后端核心模块 -| 路径 | 方法 | 模块/文件 | 功能 | 权限 | 来源 | -|------|------|-----------|------|------|------| -| /adminapi/captcha | GET | AuthController | 获取验证码 | 无 | app/routes/admin.php:20 | -| /adminapi/login | POST | AuthController | 用户登录 | 无 | app/routes/admin.php:21 | +| 模块 | 主要功能 | 文件位置 | +|------|----------|----------| +| 认证 | 管理员登录、验证码 | AuthController, AuthService | +| 管理员 | 获取管理员信息 | AdminController, AdminService, Admin 模型 | +| 菜单 | 获取路由菜单 | MenuController, MenuService | +| 系统 | 字典、配置管理 | SystemController, SystemConfigService | +| 通道管理 | 通道列表、详情、保存 | ChannelController, PaymentChannelRepository | +| 插件管理 | 插件列表、配置 Schema、产品列表 | PluginController, PluginService | +| 易支付 | submit.php/mapi.php/api.php | EpayController, EpayService | -### 5.2 用户接口 +### 4.4 前端核心模块 -| 路径 | 方法 | 模块/文件 | 功能 | 权限 | 来源 | -|------|------|-----------|------|------|------| -| /adminapi/user/getUserInfo | GET | UserController | 获取用户信息 | JWT | app/routes/admin.php:26 | +| 模块 | 主要功能 | 位置 | +|------|----------|------| +| 布局 | 系统整体布局 | src/layout/ | +| 认证 | 登录、权限控制 | src/views/login/ | +| 首页 | 数据概览 | src/views/home/ | +| 财务管理 | 结算、对账、发票 | src/views/finance/ | +| 渠道管理 | 通道配置、支付方式 | src/views/channel/ | +| 数据分析 | 交易分析、商户分析 | src/views/analysis/ | +| 系统设置 | 系统配置、字典管理 | src/views/system/ | -### 5.3 菜单接口 +## 5. API 接口设计 -| 路径 | 方法 | 模块/文件 | 功能 | 权限 | 来源 | -|------|------|-----------|------|------|------| -| /adminapi/menu/getRouters | GET | MenuController | 获取路由菜单 | JWT | app/routes/admin.php:29 | +### 5.1 管理后台(/adminapi) -### 5.4 系统接口 +| 路径 | 方法 | 控制器 | 功能 | 权限 | +|------|------|--------|------|------| +| /adminapi/captcha | GET | AuthController | 获取验证码 | 无 | +| /adminapi/login | POST | AuthController | 管理员登录 | 无 | +| /adminapi/user/getUserInfo | GET | AdminController | 获取管理员信息 | JWT | +| /adminapi/menu/getRouters | GET | MenuController | 获取路由菜单 | JWT | +| /adminapi/system/getDict[/{code}] | GET | SystemController | 获取字典 | JWT | +| /adminapi/system/base-config/tabs | GET | SystemController | 获取配置标签 | JWT | +| /adminapi/system/base-config/form/{tabKey} | GET | SystemController | 获取表单配置 | JWT | +| /adminapi/system/base-config/submit/{tabKey} | POST | SystemController | 提交配置 | JWT | +| /adminapi/channel/list | GET | ChannelController | 通道列表 | JWT | +| /adminapi/channel/detail | GET | ChannelController | 通道详情 | JWT | +| /adminapi/channel/save | POST | ChannelController | 保存通道 | JWT | +| /adminapi/channel/plugins | GET | PluginController | 插件列表 | JWT | +| /adminapi/channel/plugin/config-schema | GET | PluginController | 插件配置 Schema | JWT | +| /adminapi/channel/plugin/products | GET | PluginController | 插件产品列表 | JWT | -| 路径 | 方法 | 模块/文件 | 功能 | 权限 | 来源 | -|------|------|-----------|------|------|------| -| /adminapi/system/getDict[/{code}] | GET | SystemController | 获取字典数据 | JWT | app/routes/admin.php:32 | -| /adminapi/system/base-config/tabs | GET | SystemController | 获取配置标签 | JWT | app/routes/admin.php:35 | -| /adminapi/system/base-config/form/{tabKey} | GET | SystemController | 获取表单配置 | JWT | app/routes/admin.php:36 | -| /adminapi/system/base-config/submit/{tabKey} | POST | SystemController | 提交配置 | JWT | app/routes/admin.php:37 | +### 5.2 易支付接口(对外 API) -## 6. 技术特点 +| 路径 | 方法 | 控制器 | 功能 | 说明 | +|------|------|--------|------|------| +| /submit.php | ANY | EpayController | 页面跳转支付 | 参数:pid, key, out_trade_no, money, name, type, notify_url 等 | +| /mapi.php | POST | EpayController | API 接口支付 | 返回 trade_no、payurl/qrcode/urlscheme | +| /api.php | GET | EpayController | 订单查询/退款 | act=order 查询,act=refund 退款 | -### 6.1 后端特点 +易支付约定:`pid` 映射为 `app_id`(商户应用标识),`key` 为 `app_secret`。 -1. **高性能架构**:基于 Webman 框架,使用 Workerman 作为底层,支持高并发处理 -2. **模块化设计**:采用分层架构,清晰分离控制器、服务、仓库和模型 -3. **JWT认证**:使用 JSON Web Token 实现无状态认证 -4. **中间件机制**:通过中间件实现请求拦截和权限控制 -5. **Redis集成**:使用 Redis 作为缓存和队列存储 -6. **事件系统**:支持事件驱动架构 -7. **定时任务**:内置定时任务管理功能 -8. **数据验证**:使用 think-validate 进行数据验证 -9. **依赖注入**:使用 PHP-DI 实现依赖注入 -10. **日志管理**:使用 Monolog 进行日志管理 +## 6. 命名与约定 -### 6.2 前端特点 +### 6.1 模型与仓储命名 -1. **Vue 3 + TypeScript**:使用最新的 Vue 3 组合式 API 和 TypeScript 提供类型安全 -2. **Arco Design**:采用字节跳动开源的 Arco Design UI 组件库,提供美观的界面 -3. **Pinia 状态管理**:使用 Pinia 替代 Vuex,提供更简洁的状态管理方案 -4. **Vite 构建工具**:使用 Vite 提供快速的开发体验和优化的构建输出 -5. **国际化支持**:内置多语言支持,可轻松切换语言 -6. **响应式设计**:适配不同屏幕尺寸的设备 -7. **丰富的功能组件**:集成多种实用组件,如二维码生成、条码生成、富文本编辑等 -8. **权限控制**:基于指令的权限控制机制 -9. **Mock 数据**:内置 Mock 数据,方便开发和测试 +- 业务语义命名:`PaymentMethod`、`PaymentOrder`、`PaymentChannel` 等,不使用 `ma` 前缀 +- 表名仍为 `ma_*`,通过模型 `$table` 映射 + +### 6.2 订单相关字段 + +- 系统订单号:`order_id` +- 商户订单号:`mch_order_no` +- 商户ID:`merchant_id` +- 商户应用ID:`merchant_app_id` +- 通道ID:`channel_id` +- 支付方式ID:`method_id`(关联 ma_pay_method.id) + +### 6.3 商户应用 api_type + +用于区分不同 API 的验签与通知方式:`openapi`、`epay`、`custom` 等。 ## 7. 开发流程 ### 7.1 后端开发 -1. **环境准备**:PHP 8.1+,Composer,MySQL,Redis -2. **依赖安装**:`composer install` -3. **配置环境**:复制 `.env.example` 为 `.env` 并配置相关参数 -4. **启动服务**:`php start.php start` -5. **代码结构**:遵循 Webman 框架规范,按模块组织代码 +1. **环境**:PHP 8.1+,Composer,MySQL,Redis +2. **依赖**:`composer install` +3. **数据库**:执行 `database/mvp_payment_tables.sql` +4. **配置**:复制 `.env.example` 为 `.env` +5. **启动**: + - Linux:`php start.php start` + - Windows:`php windows.php start` ### 7.2 前端开发 -1. **环境准备**:Node.js 18.12+,PNPM 8.7+ -2. **依赖安装**:`pnpm install` -3. **开发模式**:`pnpm dev` -4. **构建部署**:`pnpm build:prod` -5. **代码结构**:遵循 Vue 3 项目规范,按功能模块组织代码 +1. **环境**:Node.js 18.12+,PNPM 8.7+ +2. **依赖**:`pnpm install` +3. **开发**:`pnpm dev` +4. **构建**:`pnpm build:prod` -## 8. 部署与配置 +## 8. 相关文档 -### 8.1 后端部署 - -1. **服务器要求**:Linux/Unix 系统,PHP 8.1+,MySQL 5.7+,Redis 5.0+ -2. **Nginx 配置**:配置反向代理指向 Webman 服务 -3. **启动方式**: - - 开发环境:`php start.php start` - - 生产环境:`php start.php start -d` -4. **监控管理**:可使用 Supervisor 管理进程 - -### 8.2 前端部署 - -1. **构建**:`pnpm build:prod` -2. **部署**:将 `dist` 目录部署到 Web 服务器 -3. **Nginx 配置**:配置静态文件服务和路由重写 +| 文件 | 说明 | +|------|------| +| doc/epay.md | 易支付接口说明 | +| doc/payment_flow.md | 支付流程说明 | +| doc/payment_system_implementation.md | 支付系统实现说明 | +| doc/validation.md | 验证规则说明 | +| database/mvp_payment_tables.sql | 支付系统表结构 | ## 9. 总结 -MPAY V2 项目采用现代化的技术栈和架构设计,后端使用 Webman 框架提供高性能的 API 服务,前端使用 Vue 3 + TypeScript + Arco Design 提供美观、响应式的用户界面。项目结构清晰,模块化程度高,便于维护和扩展。 - -核心功能覆盖了支付管理系统的主要业务场景,包括用户认证、菜单管理、系统配置、财务管理、渠道管理和数据分析等模块,为支付业务的运营和管理提供了完整的解决方案。 - -技术特点包括高性能架构、模块化设计、JWT认证、Redis集成、Vue 3组合式API、TypeScript类型安全、Arco Design UI组件库、Pinia状态管理、Vite构建工具等,确保了系统的稳定性、安全性和可扩展性。 - -该项目适合作为支付管理系统的基础框架,可根据具体业务需求进行定制和扩展。 \ No newline at end of file +MPAY V2 以支付业务为核心,采用 Webman + Vue 3 技术栈,后端分层清晰(Controller → Service → Repository → Model),支持支付插件扩展与易支付兼容。管理后台基于 JWT 认证,提供通道、插件、系统配置等管理能力;对外提供易支付标准接口(submit/mapi/api),便于第三方商户接入。 diff --git a/doc/validation.md b/doc/validation.md new file mode 100644 index 0000000..904899e --- /dev/null +++ b/doc/validation.md @@ -0,0 +1,395 @@ +验证器 webman/validation +基于 illuminate/validation,提供手动验证、注解验证、参数级验证,以及可复用的规则集。 + +安装 +composer require webman/validation +基本概念 +规则集复用:通过继承 support\validation\Validator 定义可复用的 rules messages attributes scenes,可在手动与注解中复用。 +方法级注解(Attribute)验证:使用 PHP 8 属性注解 #[Validate] 绑定控制器方法。 +参数级注解(Attribute)验证:使用 PHP 8 属性注解 #[Param] 绑定控制器方法参数。 +异常处理:验证失败抛出 support\validation\ValidationException,异常类可通过配置自定义 +数据库验证:如果涉及数据库验证,需要安装 composer require webman/database +手动验证 +基本用法 +use support\validation\Validator; + +$data = ['email' => 'user@example.com']; + +Validator::make($data, [ + 'email' => 'required|email', +])->validate(); +提示 +validate() 校验失败会抛出 support\validation\ValidationException。如果你不希望抛异常,请使用下方的 fails() 写法获取错误信息。 + +自定义 messages 与 attributes +use support\validation\Validator; + +$data = ['contact' => 'user@example.com']; + +Validator::make( + $data, + ['contact' => 'required|email'], + ['contact.email' => '邮箱格式不正确'], + ['contact' => '邮箱'] +)->validate(); +不抛异常并获取错误信息 +如果你不希望抛异常,可以使用 fails() 判断,并通过 errors()(返回 MessageBag)获取错误信息: + +use support\validation\Validator; + +$data = ['email' => 'bad-email']; + +$validator = Validator::make($data, [ + 'email' => 'required|email', +]); + +if ($validator->fails()) { + $firstError = $validator->errors()->first(); // string + $allErrors = $validator->errors()->all(); // array + $errorsByField = $validator->errors()->toArray(); // array + // 处理错误... +} +规则集复用(自定义 Validator) +namespace app\validation; + +use support\validation\Validator; + +class UserValidator extends Validator +{ + protected array $rules = [ + 'id' => 'required|integer|min:1', + 'name' => 'required|string|min:2|max:20', + 'email' => 'required|email', + ]; + + protected array $messages = [ + 'name.required' => '姓名必填', + 'email.required' => '邮箱必填', + 'email.email' => '邮箱格式不正确', + ]; + + protected array $attributes = [ + 'name' => '姓名', + 'email' => '邮箱', + ]; +} +手动验证复用 +use app\validation\UserValidator; + +UserValidator::make($data)->validate(); +使用 scenes(可选) +scenes 是可选能力,只有在你调用 withScene(...) 时,才会按场景只验证部分字段。 + +namespace app\validation; + +use support\validation\Validator; + +class UserValidator extends Validator +{ + protected array $rules = [ + 'id' => 'required|integer|min:1', + 'name' => 'required|string|min:2|max:20', + 'email' => 'required|email', + ]; + + protected array $scenes = [ + 'create' => ['name', 'email'], + 'update' => ['id', 'name', 'email'], + ]; +} +use app\validation\UserValidator; + +// 不指定场景 -> 验证全部规则 +UserValidator::make($data)->validate(); + +// 指定场景 -> 只验证该场景包含的字段 +UserValidator::make($data)->withScene('create')->validate(); +注解验证(方法级) +直接规则 +use support\Request; +use support\validation\annotation\Validate; + +class AuthController +{ + #[Validate( + rules: [ + 'email' => 'required|email', + 'password' => 'required|string|min:6', + ], + messages: [ + 'email.required' => '邮箱必填', + 'password.required' => '密码必填', + ], + attributes: [ + 'email' => '邮箱', + 'password' => '密码', + ] + )] + public function login(Request $request) + { + return json(['code' => 0, 'msg' => 'ok']); + } +} +复用规则集 +use app\validation\UserValidator; +use support\Request; +use support\validation\annotation\Validate; + +class UserController +{ + #[Validate(validator: UserValidator::class, scene: 'create')] + public function create(Request $request) + { + return json(['code' => 0, 'msg' => 'ok']); + } +} +多重验证叠加 +use support\validation\annotation\Validate; + +class UserController +{ + #[Validate(rules: ['email' => 'required|email'])] + #[Validate(rules: ['token' => 'required|string'])] + public function send() + { + return json(['code' => 0, 'msg' => 'ok']); + } +} +验证数据来源 +use support\validation\annotation\Validate; + +class UserController +{ + #[Validate( + rules: ['email' => 'required|email'], + in: ['query', 'body', 'path'] + )] + public function send() + { + return json(['code' => 0, 'msg' => 'ok']); + } +} +通过in参数来指定数据来源,其中: + +query http请求的query参数,取自 $request->get() +body http请求的包体,取自 $request->post() +path http请求的路径参数,取自 $request->route->param() +in可为字符串或数组;为数组时按顺序合并,后者覆盖前者。未传递in时默认等效于 ['query', 'body', 'path']。 + +参数级验证(Param) +基本用法 +use support\validation\annotation\Param; + +class MailController +{ + public function send( + #[Param(rules: 'required|email')] string $from, + #[Param(rules: 'required|email')] string $to, + #[Param(rules: 'required|string|min:1|max:500')] string $content + ) { + return json(['code' => 0, 'msg' => 'ok']); + } +} +验证数据来源 +类似的,参数级也支持in参数指定来源 + +use support\validation\annotation\Param; + +class MailController +{ + public function send( + #[Param(rules: 'required|email', in: ['body'])] string $from + ) { + return json(['code' => 0, 'msg' => 'ok']); + } +} +rules 支持字符串或数组 +use support\validation\annotation\Param; + +class MailController +{ + public function send( + #[Param(rules: ['required', 'email'])] string $from + ) { + return json(['code' => 0, 'msg' => 'ok']); + } +} +自定义 messages / attribute +use support\validation\annotation\Param; + +class UserController +{ + public function updateEmail( + #[Param( + rules: 'required|email', + messages: ['email.email' => '邮箱格式不正确'], + attribute: '邮箱' + )] + string $email + ) { + return json(['code' => 0, 'msg' => 'ok']); + } +} +规则常量复用 +final class ParamRules +{ + public const EMAIL = ['required', 'email']; +} + +class UserController +{ + public function send( + #[Param(rules: ParamRules::EMAIL)] string $email + ) { + return json(['code' => 0, 'msg' => 'ok']); + } +} +方法级 + 参数级混合 +use support\Request; +use support\validation\annotation\Param; +use support\validation\annotation\Validate; + +class UserController +{ + #[Validate(rules: ['token' => 'required|string'])] + public function send( + Request $request, + #[Param(rules: 'required|email')] string $from, + #[Param(rules: 'required|integer')] int $id + ) { + return json(['code' => 0, 'msg' => 'ok']); + } +} +自动规则推导(基于参数签名) +当方法上使用 #[Validate],或该方法的任意参数使用了 #[Param] 时,本组件会根据方法参数签名自动推导并补全基础验证规则,再与已有规则合并后执行验证。 + +示例:#[Validate] 等价展开 +1) 只开启 #[Validate],不手写规则: + +use support\validation\annotation\Validate; + +class DemoController +{ + #[Validate] + public function create(string $content, int $uid) + { + } +} +等价于: + +use support\validation\annotation\Validate; + +class DemoController +{ + #[Validate(rules: [ + 'content' => 'required|string', + 'uid' => 'required|integer', + ])] + public function create(string $content, int $uid) + { + } +} +2) 只写了部分规则,其余由参数签名补全: + +use support\validation\annotation\Validate; + +class DemoController +{ + #[Validate(rules: [ + 'content' => 'min:2', + ])] + public function create(string $content, int $uid) + { + } +} +等价于: + +use support\validation\annotation\Validate; + +class DemoController +{ + #[Validate(rules: [ + 'content' => 'required|string|min:2', + 'uid' => 'required|integer', + ])] + public function create(string $content, int $uid) + { + } +} +3) 默认值/可空类型: + +use support\validation\annotation\Validate; + +class DemoController +{ + #[Validate] + public function create(string $content = '默认值', ?int $uid = null) + { + } +} +等价于: + +use support\validation\annotation\Validate; + +class DemoController +{ + #[Validate(rules: [ + 'content' => 'string', + 'uid' => 'integer|nullable', + ])] + public function create(string $content = '默认值', ?int $uid = null) + { + } +} +异常处理 +默认异常 +验证失败默认抛出 support\validation\ValidationException,继承 Webman\Exception\BusinessException,不会记录错误日志。 + +默认响应行为由 BusinessException::render() 处理: + +普通请求:返回字符串消息,例如 token 为必填项。 +JSON 请求:返回 JSON 响应,例如 {"code": 422, "msg": "token 为必填项。", "data":....} +通过自定义异常修改处理方式 +全局配置:config/plugin/webman/validation/app.php 的 exception +多语言支持 +组件内置中英文语言包,并支持项目覆盖。加载顺序: + +项目语言包 resource/translations/{locale}/validation.php +组件内置 vendor/webman/validation/resources/lang/{locale}/validation.php +Illuminate 内置英文(兜底) +提示 +webman默认语言由 config/translation.php 配置,也可以通过函数 locale('en'); 更改。 + +本地覆盖示例 +resource/translations/zh_CN/validation.php + +return [ + 'email' => ':attribute 不是有效的邮件格式。', +]; +中间件自动加载 +组件安装后会通过 config/plugin/webman/validation/middleware.php 自动加载验证中间件,无需手动注册。 + +命令行生成注解 +使用命令 make:validator 生成验证器类(默认生成到 app/validation 目录)。 + +提示 +需要安装 composer require webman/console + +基础用法 +生成空模板 +php webman make:validator UserValidator +覆盖已存在文件 +php webman make:validator UserValidator --force +php webman make:validator UserValidator -f +从表结构生成规则 +指定表名生成基础规则(会根据字段类型/可空/长度等推导 $rules;默认排除字段与 ORM 相关:laravel 为 created_at/updated_at/deleted_at,thinkorm 为 create_time/update_time/delete_time) +php webman make:validator UserValidator --table=wa_users +php webman make:validator UserValidator -t wa_users +指定数据库连接(多连接场景) +php webman make:validator UserValidator --table=wa_users --database=mysql +php webman make:validator UserValidator -t wa_users -d mysql +场景(scenes) +生成 CRUD 场景:create/update/delete/detail +php webman make:validator UserValidator --table=wa_users --scenes=crud +php webman make:validator UserValidator -t wa_users -s crud +update 场景会包含主键字段(用于定位记录)以及其余字段;delete/detail 默认仅包含主键字段。 \ No newline at end of file