From fa3abdcaffde00c9ef7ceaed827b1532bc60e4ad 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: Thu, 12 Mar 2026 19:18:21 +0800 Subject: [PATCH] =?UTF-8?q?1.=20=E5=AE=8C=E5=96=84=E6=98=93=E6=94=AF?= =?UTF-8?q?=E4=BB=98API=E8=B0=83=E7=94=A8=E5=85=A8=E6=B5=81=E7=A8=8B=202.?= =?UTF-8?q?=20=E7=A1=AE=E5=AE=9A=E6=94=AF=E4=BB=98=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=BB=A7=E6=89=BF=E5=9F=BA=E7=A1=80=E7=B1=BB=E5=92=8C=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E8=A7=84=E8=8C=83=203.=20=E5=BC=95=E5=85=A5Yansongda\?= =?UTF-8?q?Pay=E6=94=AF=E4=BB=98=E5=BF=AB=E6=8D=B7=E5=B7=A5=E5=85=B7=204.?= =?UTF-8?q?=20=E9=87=8D=E6=96=B0=E6=95=B4=E7=90=86=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=92=8C=E5=8A=9F=E8=83=BD=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/common/base/BasePayment.php | 167 ++++++++++++ app/common/contracts/AbstractPayPlugin.php | 229 ----------------- app/common/contracts/PayPluginInterface.php | 120 +++------ app/common/contracts/PaymentInterface.php | 84 ++++++ app/common/payment/AlipayPayment.php | 270 ++++++++++++++++++++ app/common/payment/LakalaPayment.php | 202 ++++----------- app/common/utils/EpayUtil.php | 65 +++++ app/exceptions/PaymentException.php | 25 ++ app/http/api/controller/EpayController.php | 9 +- app/models/PaymentOrder.php | 9 +- app/routes/api.php | 3 +- app/services/PayOrderService.php | 63 +++-- app/services/PayService.php | 52 ++-- app/services/PluginService.php | 23 +- app/services/api/EpayService.php | 61 ++--- composer.json | 3 +- composer.lock | 210 ++++++++++++++- database/dev_seed.sql | 4 +- support/helpers.php | 21 ++ 19 files changed, 1042 insertions(+), 578 deletions(-) create mode 100644 app/common/base/BasePayment.php delete mode 100644 app/common/contracts/AbstractPayPlugin.php create mode 100644 app/common/contracts/PaymentInterface.php create mode 100644 app/common/payment/AlipayPayment.php create mode 100644 app/common/utils/EpayUtil.php create mode 100644 app/exceptions/PaymentException.php diff --git a/app/common/base/BasePayment.php b/app/common/base/BasePayment.php new file mode 100644 index 0000000..f62f188 --- /dev/null +++ b/app/common/base/BasePayment.php @@ -0,0 +1,167 @@ + + */ + protected array $paymentInfo = []; + + /** + * 通道配置(由 init 注入) + * + * 建议是“纯配置”:商户号/密钥/网关地址/产品开关等。 + * + * @var array + */ + protected array $channelConfig = []; + + /** HTTP 请求客户端(GuzzleHttp) */ + private ?Client $httpClient = null; + + // ==================== 初始化 ==================== + + /** + * 初始化插件,加载通道配置并创建 HTTP 客户端 + * + * @param array $channelConfig 通道配置(商户号、密钥等) + * @return void + */ + public function init(array $channelConfig): void + { + $this->channelConfig = $channelConfig; + $this->httpClient = new Client([ + 'timeout' => 10, + 'connect_timeout' => 10, + 'verify' => true, + 'http_errors' => false, + ]); + } + + /** + * 获取通道配置项 + * + * @param string $key 配置键 + * @param mixed $default 默认值(键不存在时返回) + * @return mixed + */ + protected function getConfig(string $key, mixed $default = null): mixed + { + return $this->channelConfig[$key] ?? $default; + } + + // ==================== 插件元信息 ==================== + + /** 获取插件代码(唯一标识) */ + public function getCode(): string + { + return $this->paymentInfo['code'] ?? ''; + } + + /** 获取插件名称 */ + public function getName(): string + { + return $this->paymentInfo['name'] ?? ''; + } + + /** 获取作者名称 */ + public function getAuthorName(): string + { + return $this->paymentInfo['author'] ?? ''; + } + + /** 获取作者链接 */ + public function getAuthorLink(): string + { + return $this->paymentInfo['link'] ?? ''; + } + + // ==================== 能力声明 ==================== + + /** + * 获取插件支持的支付方式列表 + * + * @return array 支付方式代码数组,如 ['alipay', 'wechat'] + */ + public function getEnabledPayTypes(): array + { + return $this->paymentInfo['pay_types'] ?? []; + } + + /** + * 获取插件支持的转账方式列表 + * + * @return array 转账方式代码数组 + */ + public function getEnabledTransferTypes(): array + { + return $this->paymentInfo['transfer_types'] ?? []; + } + + /** + * 获取插件配置表单结构(用于后台配置界面) + * + * @return array 表单字段定义数组 + */ + public function getConfigSchema(): array + { + return $this->paymentInfo['config_schema'] ?? []; + } + + // ==================== HTTP 请求 ==================== + + /** + * 统一 HTTP 请求(对外调用支付渠道 API) + * + * @param string $method 请求方法(GET/POST/PUT/DELETE 等) + * @param string $url 请求地址 + * @param array $options Guzzle 请求选项(headers、json、form_params 等) + * @return ResponseInterface + * @throws PaymentException 未调用 init() 或渠道请求失败时 + */ + protected function request(string $method, string $url, array $options = []): ResponseInterface + { + if ($this->httpClient === null) { + throw new PaymentException('支付插件未初始化,请先调用 init()'); + } + + try { + return $this->httpClient->request($method, $url, $options); + } catch (GuzzleException $e) { + Log::error(sprintf('[BasePayment] HTTP 请求失败: %s %s, error=%s', $method, $url, $e->getMessage())); + throw new PaymentException('渠道请求失败:' . $e->getMessage(), 402, ['method' => $method, 'url' => $url]); + } + } +} diff --git a/app/common/contracts/AbstractPayPlugin.php b/app/common/contracts/AbstractPayPlugin.php deleted file mode 100644 index 62f201e..0000000 --- a/app/common/contracts/AbstractPayPlugin.php +++ /dev/null @@ -1,229 +0,0 @@ -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 index 12fcf92..847842e 100644 --- a/app/common/contracts/PayPluginInterface.php +++ b/app/common/contracts/PayPluginInterface.php @@ -1,103 +1,49 @@ $channelConfig */ - public static function getCode(): string; - + public function init(array $channelConfig): void; + + /** 插件代码(与 ma_pay_plugin.plugin_code 对应) */ + public function getCode(): string; + + /** 插件名称(用于后台展示) */ + public function getName(): string; + /** - * 获取插件名称 - * - * @return string + * 插件声明支持的支付方式编码 + * + * @return array */ - public static function getName(): string; - + public function getEnabledPayTypes(): array; + /** - * 获取插件支持的支付方式列表 - * - * @return array 支付方式代码数组,如 ['alipay', 'wechat'] + * 插件配置结构(用于后台渲染表单/校验) + * + * @return array */ - 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; + public function getConfigSchema(): array; } diff --git a/app/common/contracts/PaymentInterface.php b/app/common/contracts/PaymentInterface.php new file mode 100644 index 0000000..9836b77 --- /dev/null +++ b/app/common/contracts/PaymentInterface.php @@ -0,0 +1,84 @@ + $order 订单数据,通常包含: + * - order_id: 系统订单号 + * - mch_no: 商户号 + * - amount: 金额(元) + * - subject: 商品标题 + * - body: 商品描述 + * @return array 支付参数,需包含 pay_params、chan_order_no、chan_trade_no + * @throws PaymentException 下单失败、渠道异常、参数错误等 + */ + public function pay(array $order): array; + + /** + * 查询订单状态 + * + * @param array $order 订单数据(至少含 order_id、chan_order_no) + * @return array 订单状态信息,通常包含: + * - status: 订单状态 + * - chan_trade_no: 渠道交易号 + * - pay_amount: 实付金额 + * @throws PaymentException 查询失败、渠道异常等 + */ + public function query(array $order): array; + + /** + * 关闭订单 + * + * @param array $order 订单数据(至少含 order_id、chan_order_no) + * @return array 关闭结果,通常包含 success、msg + * @throws PaymentException 关闭失败、渠道异常等 + */ + public function close(array $order): array; + + /** + * 申请退款 + * + * @param array $order 退款数据,通常包含: + * - order_id: 原订单号 + * - chan_order_no: 渠道订单号 + * - refund_amount: 退款金额 + * - refund_no: 退款单号 + * @return array 退款结果,通常包含 success、chan_refund_no、msg + * @throws PaymentException 退款失败、渠道异常等 + */ + public function refund(array $order): array; + + // ==================== 异步通知 ==================== + + /** + * 解析并验证支付回调通知 + * + * @param Request $request 支付渠道的异步通知请求(GET/POST 参数) + * @return array 解析结果,通常包含: + * - status: 支付状态 + * - pay_order_id: 系统订单号 + * - chan_trade_no: 渠道交易号 + * - amount: 支付金额 + * @throws PaymentException 验签失败、数据异常等 + */ + public function notify(Request $request): array; +} diff --git a/app/common/payment/AlipayPayment.php b/app/common/payment/AlipayPayment.php new file mode 100644 index 0000000..b2e014e --- /dev/null +++ b/app/common/payment/AlipayPayment.php @@ -0,0 +1,270 @@ + 'alipay', + 'name' => '支付宝直连', + 'author' => '', + 'link' => '', + 'pay_types' => ['alipay'], + 'transfer_types' => [], + 'config_schema' => [ + 'fields' => [ + ['field' => 'app_id', 'label' => '应用ID', 'type' => 'text', 'required' => true], + ['field' => 'app_secret_cert', 'label' => '应用私钥', 'type' => 'textarea', 'required' => true], + ['field' => 'app_public_cert_path', 'label' => '应用公钥证书路径', 'type' => 'text', 'required' => true], + ['field' => 'alipay_public_cert_path', 'label' => '支付宝公钥证书路径', 'type' => 'text', 'required' => true], + ['field' => 'alipay_root_cert_path', 'label' => '支付宝根证书路径', 'type' => 'text', 'required' => true], + ['field' => 'notify_url', 'label' => '异步通知地址', 'type' => 'text', 'required' => true], + ['field' => 'return_url', 'label' => '同步跳转地址', 'type' => 'text', 'required' => false], + ['field' => 'mode', 'label' => '环境', 'type' => 'select', 'options' => [['value' => '0', 'label' => '正式'], ['value' => '1', 'label' => '沙箱']]], + ], + ], + ]; + + private const PRODUCT_WEB = 'alipay_web'; + private const PRODUCT_H5 = 'alipay_h5'; + private const PRODUCT_SCAN = 'alipay_scan'; + private const PRODUCT_APP = 'alipay_app'; + + public function init(array $channelConfig): void + { + parent::init($channelConfig); + Pay::config([ + 'alipay' => [ + 'default' => [ + 'app_id' => $this->getConfig('app_id', ''), + 'app_secret_cert' => $this->getConfig('app_secret_cert', ''), + 'app_public_cert_path' => $this->getConfig('app_public_cert_path', ''), + 'alipay_public_cert_path' => $this->getConfig('alipay_public_cert_path', ''), + 'alipay_root_cert_path' => $this->getConfig('alipay_root_cert_path', ''), + 'notify_url' => $this->getConfig('notify_url', ''), + 'return_url' => $this->getConfig('return_url', ''), + 'mode' => (int)($this->getConfig('mode', Pay::MODE_NORMAL)), + ], + ], + ]); + } + + private function chooseProduct(array $order): string + { + $enabled = $this->channelConfig['enabled_products'] ?? ['alipay_web', 'alipay_h5', 'alipay_scan']; + $env = $order['_env'] ?? 'pc'; + $map = ['pc' => self::PRODUCT_WEB, 'h5' => self::PRODUCT_H5, 'alipay' => self::PRODUCT_APP]; + $prefer = $map[$env] ?? self::PRODUCT_WEB; + return in_array($prefer, $enabled, true) ? $prefer : ($enabled[0] ?? self::PRODUCT_WEB); + } + + public function pay(array $order): array + { + $orderId = $order['order_id'] ?? $order['mch_no'] ?? ''; + $amount = (float)($order['amount'] ?? 0); + $subject = (string)($order['subject'] ?? ''); + $extra = $order['extra'] ?? []; + $returnUrl = $extra['return_url'] ?? $this->getConfig('return_url', ''); + $notifyUrl = $this->getConfig('notify_url', ''); + + $params = [ + 'out_trade_no' => $orderId, + 'total_amount' => sprintf('%.2f', $amount), + 'subject' => $subject, + ]; + if ($returnUrl !== '') { + $params['_return_url'] = $returnUrl; + } + if ($notifyUrl !== '') { + $params['_notify_url'] = $notifyUrl; + } + + $product = $this->chooseProduct($order); + + try { + return match ($product) { + self::PRODUCT_WEB => $this->doWeb($params), + self::PRODUCT_H5 => $this->doH5($params), + self::PRODUCT_SCAN => $this->doScan($params), + self::PRODUCT_APP => $this->doApp($params), + default => throw new PaymentException('不支持的支付宝产品:' . $product, 402), + }; + } catch (PaymentException $e) { + throw $e; + } catch (\Throwable $e) { + throw new PaymentException('支付宝下单失败:' . $e->getMessage(), 402, ['order_id' => $orderId]); + } + } + + private function doWeb(array $params): array + { + $response = Pay::alipay()->web($params); + $body = $response instanceof ResponseInterface ? (string)$response->getBody() : ''; + return [ + 'pay_params' => ['type' => 'form', 'method' => 'POST', 'action' => '', 'html' => $body], + 'chan_order_no' => $params['out_trade_no'], + 'chan_trade_no' => '', + ]; + } + + private function doH5(array $params): array + { + $returnUrl = $params['_return_url'] ?? $this->getConfig('return_url', ''); + if ($returnUrl !== '') { + $params['quit_url'] = $returnUrl; + } + $response = Pay::alipay()->h5($params); + $body = $response instanceof ResponseInterface ? (string)$response->getBody() : ''; + return [ + 'pay_params' => ['type' => 'form', 'method' => 'POST', 'action' => '', 'html' => $body], + 'chan_order_no' => $params['out_trade_no'], + 'chan_trade_no' => '', + ]; + } + + private function doScan(array $params): array + { + /** @var Collection $result */ + $result = Pay::alipay()->scan($params); + $qrCode = $result->get('qr_code', ''); + return [ + 'pay_params' => ['type' => 'qrcode', 'qrcode_url' => $qrCode, 'qrcode_data' => $qrCode], + 'chan_order_no' => $params['out_trade_no'], + 'chan_trade_no' => $result->get('trade_no', ''), + ]; + } + + private function doApp(array $params): array + { + /** @var Collection $result */ + $result = Pay::alipay()->app($params); + $orderStr = $result->get('order_string', ''); + return [ + 'pay_params' => ['type' => 'jsapi', 'order_str' => $orderStr, 'urlscheme' => $orderStr], + 'chan_order_no' => $params['out_trade_no'], + 'chan_trade_no' => $result->get('trade_no', ''), + ]; + } + + public function query(array $order): array + { + $outTradeNo = $order['chan_order_no'] ?? $order['order_id'] ?? ''; + + try { + /** @var Collection $result */ + $result = Pay::alipay()->query(['out_trade_no' => $outTradeNo]); + $tradeStatus = $result->get('trade_status', ''); + $tradeNo = $result->get('trade_no', ''); + $totalAmount = (float)$result->get('total_amount', 0); + $status = in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true) ? 'success' : $tradeStatus; + + return [ + 'status' => $status, + 'chan_trade_no' => $tradeNo, + 'pay_amount' => $totalAmount, + ]; + } catch (\Throwable $e) { + throw new PaymentException('支付宝查询失败:' . $e->getMessage(), 402); + } + } + + public function close(array $order): array + { + $outTradeNo = $order['chan_order_no'] ?? $order['order_id'] ?? ''; + + try { + Pay::alipay()->close(['out_trade_no' => $outTradeNo]); + return ['success' => true, 'msg' => '关闭成功']; + } catch (\Throwable $e) { + throw new PaymentException('支付宝关单失败:' . $e->getMessage(), 402); + } + } + + public function refund(array $order): array + { + $outTradeNo = $order['chan_order_no'] ?? $order['order_id'] ?? ''; + $refundAmount = (float)($order['refund_amount'] ?? 0); + $refundNo = $order['refund_no'] ?? $order['order_id'] . '_' . time(); + $refundReason = (string)($order['refund_reason'] ?? ''); + + if ($outTradeNo === '' || $refundAmount <= 0) { + throw new PaymentException('退款参数不完整', 402); + } + + $params = [ + 'out_trade_no' => $outTradeNo, + 'refund_amount' => sprintf('%.2f', $refundAmount), + 'out_request_no' => $refundNo, + ]; + if ($refundReason !== '') { + $params['refund_reason'] = $refundReason; + } + + try { + /** @var Collection $result */ + $result = Pay::alipay()->refund($params); + $code = $result->get('code'); + $subMsg = $result->get('sub_msg', ''); + + if ($code === '10000' || $code === 10000) { + return [ + 'success' => true, + 'chan_refund_no'=> $result->get('trade_no', $refundNo), + 'msg' => '退款成功', + ]; + } + throw new PaymentException($subMsg ?: '退款失败', 402); + } catch (PaymentException $e) { + throw $e; + } catch (\Throwable $e) { + throw new PaymentException('支付宝退款失败:' . $e->getMessage(), 402); + } + } + + public function notify(Request $request): array + { + $params = array_merge($request->get(), $request->post()); + + try { + /** @var Collection $result */ + $result = Pay::alipay()->callback($params); + $tradeStatus = $result->get('trade_status', ''); + $outTradeNo = $result->get('out_trade_no', ''); + $tradeNo = $result->get('trade_no', ''); + $totalAmount = (float)$result->get('total_amount', 0); + + if (!in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) { + throw new PaymentException('回调状态异常:' . $tradeStatus, 402); + } + + return [ + 'status' => 'success', + 'pay_order_id' => $outTradeNo, + 'chan_trade_no'=> $tradeNo, + 'amount' => $totalAmount, + ]; + } catch (PaymentException $e) { + throw $e; + } catch (\Throwable $e) { + throw new PaymentException('支付宝回调验签失败:' . $e->getMessage(), 402); + } + } +} diff --git a/app/common/payment/LakalaPayment.php b/app/common/payment/LakalaPayment.php index 24e7e74..20299f7 100644 --- a/app/common/payment/LakalaPayment.php +++ b/app/common/payment/LakalaPayment.php @@ -1,180 +1,78 @@ [ - ['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'], + protected array $paymentInfo = [ + 'code' => 'lakala', + 'name' => '拉卡拉(示例)', + 'author' => '', + 'link' => '', + 'pay_types' => ['alipay', 'wechat'], + 'transfer_types' => [], + 'config_schema' => [ + 'fields' => [ + ['field' => 'notify_url', 'label' => '异步通知地址', 'type' => 'text', 'required' => false], ], - '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 + public function pay(array $order): 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], - ]; + $orderId = (string)($order['order_id'] ?? ''); + $amount = (string)($order['amount'] ?? '0.00'); + $extra = is_array($order['extra'] ?? null) ? $order['extra'] : []; - // 根据支付方式添加特定字段 - if ($methodCode === 'alipay') { - $baseFields[] = ['field' => 'alipay_app_id', 'label' => '支付宝AppId', 'type' => 'input']; - } elseif ($methodCode === 'wechat') { - $baseFields[] = ['field' => 'wechat_app_id', 'label' => '微信AppId', 'type' => 'input']; + if ($orderId === '') { + throw new PaymentException('缺少订单号', 402); } - return ['fields' => $baseFields]; - } + // 这里先返回“可联调”的 pay_params:默认给一个 qrcode 字符串 + // 真实实现中应调用拉卡拉下单接口,返回二维码链接/支付链接/预支付信息等。 + $qrcode = $extra['mock_qrcode'] ?? ('LAKALA_MOCK_QRCODE:' . $orderId . ':' . $amount); - /** - * 统一下单 - */ - 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'], + 'pay_params' => [ + 'type' => 'qrcode', + 'qrcode_url' => $qrcode, + 'qrcode_data'=> $qrcode, ], + 'chan_order_no' => $orderId, + 'chan_trade_no' => '', ]; } - private function createWechatOrder(array $orderData, array $config, string $productCode): array + public function query(array $order): 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', - ], - ]; + throw new PaymentException('LakalaPayment::query 暂未实现', 402); } - private function createUnionpayOrder(array $orderData, array $config, string $productCode): array + public function close(array $order): array { - // TODO: 调用拉卡拉的云闪付接口 - return [ - 'product_code' => $productCode, - 'channel_order_no'=> '', - 'pay_params' => [ - 'type' => 'redirect', - 'url' => 'https://example.com/unionpay?order=' . $orderData['pay_order_id'], - ], - ]; + throw new PaymentException('LakalaPayment::close 暂未实现', 402); + } + + public function refund(array $order): array + { + throw new PaymentException('LakalaPayment::refund 暂未实现', 402); + } + + public function notify(Request $request): array + { + throw new PaymentException('LakalaPayment::notify 暂未实现', 402); } } - diff --git a/app/common/utils/EpayUtil.php b/app/common/utils/EpayUtil.php new file mode 100644 index 0000000..d9dbee4 --- /dev/null +++ b/app/common/utils/EpayUtil.php @@ -0,0 +1,65 @@ + $params 请求参数 + */ + public static function make(array $params, string $secret): string + { + unset($params['sign'], $params['sign_type']); + + $filtered = []; + foreach ($params as $k => $v) { + if ($v === null) { + continue; + } + if (is_string($v) && trim($v) === '') { + continue; + } + $filtered[$k] = is_bool($v) ? ($v ? '1' : '0') : (string)$v; + } + + ksort($filtered); + + $pairs = []; + foreach ($filtered as $k => $v) { + $pairs[] = $k . '=' . $v; + } + + $pairs[] = 'key=' . $secret; + + return strtolower(md5(implode('&', $pairs))); + } + + /** + * 校验签名 + * + * @param array $params + */ + public static function verify(array $params, string $secret): bool + { + $sign = strtolower((string)($params['sign'] ?? '')); + if ($sign === '') { + return false; + } + + return hash_equals(self::make($params, $secret), $sign); + } +} + diff --git a/app/exceptions/PaymentException.php b/app/exceptions/PaymentException.php new file mode 100644 index 0000000..12ecacd --- /dev/null +++ b/app/exceptions/PaymentException.php @@ -0,0 +1,25 @@ + 'lakala']); + */ +class PaymentException extends BusinessException +{ + public function __construct(string $message = '支付业务异常', int $bizCode = 402, array $data = []) + { + parent::__construct($message, $bizCode); + if (!empty($data)) { + $this->data($data); + } + } +} diff --git a/app/http/api/controller/EpayController.php b/app/http/api/controller/EpayController.php index 7d9963d..d29359e 100644 --- a/app/http/api/controller/EpayController.php +++ b/app/http/api/controller/EpayController.php @@ -22,7 +22,11 @@ class EpayController extends BaseController */ public function submit(Request $request) { - $data = array_merge($request->get(), $request->post()); + $data = match ($request->method()) { + 'GET' => $request->get(), + 'POST' => $request->post(), + default => array_merge($request->get(), $request->post()), + }; try { // 参数校验(使用自定义 Validator + 场景) @@ -40,6 +44,9 @@ class EpayController extends BaseController } if (($payParams['type'] ?? '') === 'form') { + if (!empty($payParams['html'])) { + return response($payParams['html'])->withHeaders(['Content-Type' => 'text/html; charset=UTF-8']); + } return $this->renderForm($payParams); } diff --git a/app/models/PaymentOrder.php b/app/models/PaymentOrder.php index 4296c0f..eff150b 100644 --- a/app/models/PaymentOrder.php +++ b/app/models/PaymentOrder.php @@ -55,8 +55,9 @@ class PaymentOrder extends BaseModel 'expire_at' => 'datetime', ]; - const STATUS_PENDING = 0; - const STATUS_SUCCESS = 1; - const STATUS_FAIL = 2; - const STATUS_CLOSED = 3; + /* 订单状态 */ + const STATUS_PENDING = 0; // 待支付 + const STATUS_SUCCESS = 1; // 支付成功 + const STATUS_FAIL = 2; // 支付失败 + const STATUS_CLOSED = 3; // 已关闭 } diff --git a/app/routes/api.php b/app/routes/api.php index 8ef1bd5..1f1eabc 100644 --- a/app/routes/api.php +++ b/app/routes/api.php @@ -6,7 +6,6 @@ use Webman\Route; use app\http\api\controller\EpayController; -use app\http\api\middleware\EpayAuthMiddleware; Route::group('', function () { // 页面跳转支付 @@ -17,4 +16,4 @@ Route::group('', function () { // API接口 Route::get('/api.php', [EpayController::class, 'api']); -})->middleware([EpayAuthMiddleware::class]); +}); diff --git a/app/services/PayOrderService.php b/app/services/PayOrderService.php index 00dc74b..8731dbb 100644 --- a/app/services/PayOrderService.php +++ b/app/services/PayOrderService.php @@ -6,6 +6,7 @@ use app\common\base\BaseService; use app\exceptions\{BadRequestException, NotFoundException}; use app\models\PaymentOrder; use app\repositories\{MerchantAppRepository, PaymentChannelRepository, PaymentMethodRepository, PaymentOrderRepository}; +use Illuminate\Database\QueryException; /** * 支付订单服务 @@ -28,11 +29,11 @@ class PayOrderService extends BaseService public function createOrder(array $data) { // 1. 基本参数校验 - $mchId = (int)($data['mch_id'] ?? $data['merchant_id'] ?? 0); + $mchId = (int)($data['mch_id'] ?? 0); $appId = (int)($data['app_id'] ?? 0); - $mchNo = trim((string)($data['mch_no'] ?? $data['mch_order_no'] ?? '')); - $methodCode = trim((string)($data['method_code'] ?? '')); - $amount = (float)($data['amount'] ?? 0); + $mchNo = trim((string)($data['mch_order_no'] ?? '')); + $payType = trim((string)($data['pay_type'] ?? '')); + $amountFloat = (float)($data['amount'] ?? 0); $subject = trim((string)($data['subject'] ?? '')); if ($mchId <= 0 || $appId <= 0) { @@ -41,10 +42,10 @@ class PayOrderService extends BaseService if ($mchNo === '') { throw new BadRequestException('商户订单号不能为空'); } - if ($methodCode === '') { + if ($payType === '') { throw new BadRequestException('支付方式不能为空'); } - if ($amount <= 0) { + if ($amountFloat <= 0) { throw new BadRequestException('订单金额必须大于0'); } if ($subject === '') { @@ -52,12 +53,13 @@ class PayOrderService extends BaseService } // 2. 查询支付方式ID - $method = $this->methodRepository->findByCode($methodCode); + $method = $this->methodRepository->findByCode($payType); if (!$method) { throw new BadRequestException('支付方式不存在'); } // 3. 幂等校验:同一商户应用下相同商户订单号只保留一条 + // 先查一次(减少异常成本),并发场景再用唯一键冲突兜底 $existing = $this->orderRepository->findByMchNo($mchId, $appId, $mchNo); if ($existing) { return $existing; @@ -65,25 +67,34 @@ class PayOrderService extends BaseService // 4. 生成系统订单号 $orderId = $this->generateOrderId(); + $amount = sprintf('%.2f', $amountFloat); // 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'] ?? [], - ]); + $expireTime = (int)sys_config('order_expire_time', 0); // 0 表示不设置过期时间 + try { + return $this->orderRepository->create([ + 'order_id' => $orderId, + 'merchant_id' => $mchId, + 'merchant_app_id' => $appId, + 'mch_order_no' => $mchNo, + 'method_id' => $method->id, + 'amount' => $amount, + 'real_amount' => $amount, + 'subject' => $subject, + 'body' => $data['body'] ?? $subject, + 'status' => PaymentOrder::STATUS_PENDING, + 'client_ip' => $data['client_ip'] ?? '', + 'expire_at' => $expireTime > 0 ? date('Y-m-d H:i:s', time() + $expireTime) : null, + 'extra' => $data['extra'] ?? [], + ]); + } catch (QueryException $e) { + // 并发场景:唯一键 uk_mch_order 冲突时回查返回已有订单 + $existing = $this->orderRepository->findByMchNo($mchId, $appId, $mchNo); + if ($existing) { + return $existing; + } + throw $e; + } } /** @@ -142,7 +153,7 @@ class PayOrderService extends BaseService $channel->getConfigArray(), ['enabled_products' => $channel->getEnabledProducts()] ); - $plugin->init($method->method_code, $channelConfig); + $plugin->init($channelConfig); // 7. 调用插件退款 $refundData = [ @@ -153,7 +164,7 @@ class PayOrderService extends BaseService 'refund_reason' => $data['refund_reason'] ?? '', ]; - $refundResult = $plugin->refund($refundData, $channelConfig); + $refundResult = $plugin->refund($refundData); // 8. 如果是全额退款则关闭订单 if ($refundAmount >= $order->amount) { diff --git a/app/services/PayService.php b/app/services/PayService.php index 9f74a43..b67fb94 100644 --- a/app/services/PayService.php +++ b/app/services/PayService.php @@ -3,10 +3,10 @@ namespace app\services; use app\common\base\BaseService; +use app\common\contracts\PayPluginInterface; use app\exceptions\NotFoundException; use app\models\PaymentOrder; use app\repositories\{PaymentMethodRepository, PaymentOrderRepository}; -use app\common\contracts\AbstractPayPlugin; use support\Request; /** @@ -37,7 +37,7 @@ class PayService extends BaseService * - mch_no * - pay_params */ - public function unifiedPay(array $orderData, array $options = []): array + public function pay(array $orderData, array $options = []): array { // 1. 创建订单(幂等) /** @var PaymentOrder $order */ @@ -63,7 +63,7 @@ class PayService extends BaseService $channel->getConfigArray(), ['enabled_products' => $channel->getEnabledProducts()] ); - $plugin->init($method->method_code, $channelConfig); + $plugin->init($channelConfig); // 5. 环境检测 $device = $options['device'] ?? ''; @@ -75,23 +75,27 @@ class PayService extends BaseService } elseif ($request instanceof Request) { $env = $this->detectEnvironment($request); } else { - $env = AbstractPayPlugin::ENV_PC; + $env = 'pc'; } // 6. 调用插件统一下单 $pluginOrderData = [ 'order_id' => $order->order_id, - 'mch_no' => $order->mch_order_no, - 'amount' => $order->amount, - 'subject' => $order->subject, - 'body' => $order->body, + 'mch_no' => $order->mch_order_no, + 'amount' => $order->amount, + 'subject' => $order->subject, + 'body' => $order->body, + 'extra' => $order->extra ?? [], + '_env' => $env, ]; - $payResult = $plugin->unifiedOrder($pluginOrderData, $channelConfig, $env); + $payResult = $plugin->pay($pluginOrderData); // 7. 计算实际支付金额(扣除手续费) - $fee = $order->fee > 0 ? $order->fee : ($order->amount * ($channel->chan_cost / 100)); - $realAmount = $order->amount - $fee; + $amount = (float)$order->amount; + $chanCost = (float)$channel->chan_cost; + $fee = ((float)$order->fee) > 0 ? (float)$order->fee : round($amount * ($chanCost / 100), 2); + $realAmount = round($amount - $fee, 2); // 8. 更新订单(通道、支付参数、实际金额) $extra = $order->extra ?? []; @@ -103,8 +107,8 @@ class PayService extends BaseService 'channel_id' => $channel->id, 'chan_order_no' => $chanOrderNo, 'chan_trade_no' => $chanTradeNo, - 'real_amount' => $realAmount, - 'fee' => $fee, + 'real_amount' => sprintf('%.2f', $realAmount), + 'fee' => sprintf('%.2f', $fee), 'extra' => $extra, ]); @@ -123,21 +127,21 @@ class PayService extends BaseService $ua = strtolower($request->header('User-Agent', '')); if (strpos($ua, 'alipayclient') !== false) { - return AbstractPayPlugin::ENV_ALIPAY_CLIENT; + return 'alipay'; } if (strpos($ua, 'micromessenger') !== false) { - return AbstractPayPlugin::ENV_WECHAT; + return 'wechat'; } $mobileKeywords = ['mobile', 'android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone']; foreach ($mobileKeywords as $keyword) { if (strpos($ua, $keyword) !== false) { - return AbstractPayPlugin::ENV_H5; + return 'h5'; } } - return AbstractPayPlugin::ENV_PC; + return 'pc'; } /** @@ -146,15 +150,15 @@ class PayService extends BaseService 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, + 'pc' => 'pc', + 'mobile' => 'h5', + 'qq' => 'h5', + 'wechat' => 'wechat', + 'alipay' => 'alipay', + 'jump' => 'pc', ]; - return $mapping[strtolower($device)] ?? AbstractPayPlugin::ENV_PC; + return $mapping[strtolower($device)] ?? 'pc'; } } diff --git a/app/services/PluginService.php b/app/services/PluginService.php index 023211e..4941341 100644 --- a/app/services/PluginService.php +++ b/app/services/PluginService.php @@ -3,7 +3,8 @@ namespace app\services; use app\common\base\BaseService; -use app\common\contracts\AbstractPayPlugin; +use app\common\contracts\PaymentInterface; +use app\common\contracts\PayPluginInterface; use app\exceptions\NotFoundException; use app\repositories\PaymentPluginRepository; @@ -36,8 +37,8 @@ class PluginService extends BaseService $plugin = $this->resolvePlugin($pluginCode, $row->class_name); $plugins[] = [ 'code' => $pluginCode, - 'name' => $plugin::getName(), - 'supported_methods'=> $plugin::getSupportedMethods(), + 'name' => $plugin->getName(), + 'supported_methods'=> $plugin->getEnabledPayTypes(), ]; } catch (\Throwable $e) { // 忽略无法实例化的插件 @@ -54,7 +55,7 @@ class PluginService extends BaseService public function getConfigSchema(string $pluginCode, string $methodCode): array { $plugin = $this->getPluginInstance($pluginCode); - return $plugin::getConfigSchema($methodCode); + return $plugin->getConfigSchema(); } /** @@ -62,8 +63,12 @@ class PluginService extends BaseService */ public function getSupportedProducts(string $pluginCode, string $methodCode): array { + /** @var mixed $plugin */ $plugin = $this->getPluginInstance($pluginCode); - return $plugin::getSupportedProducts($methodCode); + if (method_exists($plugin, 'getSupportedProducts')) { + return (array)$plugin->getSupportedProducts($methodCode); + } + return []; } /** @@ -72,7 +77,7 @@ class PluginService extends BaseService public function buildConfigFromForm(string $pluginCode, string $methodCode, array $formData): array { $plugin = $this->getPluginInstance($pluginCode); - $configSchema = $plugin::getConfigSchema($methodCode); + $configSchema = $plugin->getConfigSchema(); $configJson = []; if (isset($configSchema['fields']) && is_array($configSchema['fields'])) { @@ -90,7 +95,7 @@ class PluginService extends BaseService /** * 对外统一提供:根据插件编码获取插件实例 */ - public function getPluginInstance(string $pluginCode): AbstractPayPlugin + public function getPluginInstance(string $pluginCode): PaymentInterface&PayPluginInterface { $row = $this->pluginRepository->findActiveByCode($pluginCode); if (!$row) { @@ -103,7 +108,7 @@ class PluginService extends BaseService /** * 根据插件编码和 class_name 解析并实例化插件 */ - private function resolvePlugin(string $pluginCode, ?string $className = null): AbstractPayPlugin + private function resolvePlugin(string $pluginCode, ?string $className = null): PaymentInterface&PayPluginInterface { $class = $className ?: 'app\\common\\payment\\' . ucfirst($pluginCode) . 'Payment'; @@ -112,7 +117,7 @@ class PluginService extends BaseService } $plugin = new $class(); - if (!$plugin instanceof AbstractPayPlugin) { + if (!$plugin instanceof PaymentInterface || !$plugin instanceof PayPluginInterface) { throw new NotFoundException('支付插件类型错误:' . $class); } diff --git a/app/services/api/EpayService.php b/app/services/api/EpayService.php index 7644d7c..79c4aa1 100644 --- a/app/services/api/EpayService.php +++ b/app/services/api/EpayService.php @@ -3,11 +3,12 @@ namespace app\services\api; use app\common\base\BaseService; +use app\common\utils\EpayUtil; use app\services\PayOrderService; use app\services\PayService; use app\repositories\{MerchantAppRepository, PaymentMethodRepository, PaymentOrderRepository}; use app\models\PaymentOrder; -use app\exceptions\{BadRequestException, NotFoundException}; +use app\exceptions\{BadRequestException, NotFoundException, UnauthorizedException}; use support\Request; /** @@ -37,7 +38,7 @@ class EpayService extends BaseService throw new BadRequestException('暂不支持收银台模式,请指定支付方式 type'); } - return $this->createUnifiedOrder($data, $request); + return $this->createOrder($data, $request); } /** @@ -49,7 +50,7 @@ class EpayService extends BaseService */ public function mapi(array $data, Request $request): array { - $result = $this->createUnifiedOrder($data, $request); + $result = $this->createOrder($data, $request); $payParams = $result['pay_params'] ?? []; $response = [ @@ -142,7 +143,7 @@ class EpayService extends BaseService 'trade_no' => $order->order_id, 'out_trade_no' => $order->mch_order_no, 'api_trade_no' => $order->chan_trade_no ?? '', - 'type' => $this->mapMethodToEpayType($methodCode), + 'type' => $methodCode, 'pid' => (int)$pid, 'addtime' => $order->created_at, 'endtime' => $order->pay_at, @@ -210,11 +211,11 @@ class EpayService extends BaseService * @param Request $request * @return array */ - private function createUnifiedOrder(array $data, Request $request): array + private function createOrder(array $data, Request $request): array { $pid = (int)($data['pid'] ?? 0); if ($pid <= 0) { - throw new BadRequestException('商户ID不能为空'); + throw new BadRequestException('应用ID不能为空'); } // 根据 pid 映射应用(约定 pid = app_id) @@ -223,14 +224,21 @@ class EpayService extends BaseService throw new NotFoundException('商户应用不存在或已禁用'); } - $methodCode = $this->mapEpayTypeToMethod($data['type'] ?? ''); + // 易支付签名校验:使用 app_secret 作为 key + $signType = strtolower((string)($data['sign_type'] ?? 'md5')); + if ($signType !== 'md5') { + throw new BadRequestException('不支持的签名类型:' . ($data['sign_type'] ?? '')); + } + if (!EpayUtil::verify($data, (string)$app->app_secret)) { + throw new UnauthorizedException('签名验证失败'); + } + $orderData = [ - 'merchant_id' => $app->merchant_id, + 'mch_id' => $app->merchant_id, 'app_id' => $app->id, 'mch_order_no' => $data['out_trade_no'], - 'method_code' => $methodCode, - 'amount' => (float)$data['money'], - 'currency' => 'CNY', + 'pay_type' => $data['type'], + 'amount' => sprintf('%.2f', (float)$data['money']), 'subject' => $data['name'], 'body' => $data['name'], 'client_ip' => $data['clientip'] ?? $request->getRemoteIp(), @@ -242,26 +250,12 @@ class EpayService extends BaseService ]; // 调用通用支付服务完成通道选择与插件下单 - return $this->payService->unifiedPay($orderData, [ + return $this->payService->pay($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; - } - /** * 根据订单获取支付方式编码 */ @@ -270,19 +264,4 @@ class EpayService extends BaseService $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/composer.json b/composer.json index 9bb4650..faa8a9c 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,8 @@ "webman/redis-queue": "^2.1", "firebase/php-jwt": "^7.0", "webman/validation": "^2.2", - "illuminate/pagination": "^12.53" + "illuminate/pagination": "^12.53", + "yansongda/pay": "~3.7.0" }, "suggest": { "ext-event": "For better performance. " diff --git a/composer.lock b/composer.lock index f171767..cfda262 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "49b81bfe43345cf72dbd33c88720b5a2", + "content-hash": "ef4cd33c0940c77fc8b5fffd35cd9bb7", "packages": [ { "name": "brick/math", @@ -6115,6 +6115,214 @@ } ], "time": "2026-01-09T03:26:15+00:00" + }, + { + "name": "yansongda/artful", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/yansongda/artful.git", + "reference": "ddc203ef34ab369a5a31df057a0fda697d3ed855" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/yansongda/artful/zipball/ddc203ef34ab369a5a31df057a0fda697d3ed855", + "reference": "ddc203ef34ab369a5a31df057a0fda697d3ed855", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^2.6", + "php": ">=8.0", + "psr/container": "^1.1 || ^2.0", + "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "psr/log": "^1.1 || ^2.0 || ^3.0", + "yansongda/supports": "~4.0.10" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.44", + "guzzlehttp/guzzle": "^7.0", + "hyperf/pimple": "^2.2", + "mockery/mockery": "^1.4", + "monolog/monolog": "^2.2", + "phpstan/phpstan": "^1.0.0 || ^2.0.0", + "phpunit/phpunit": "^9.0", + "symfony/event-dispatcher": "^5.2.0", + "symfony/http-foundation": "^5.2.0", + "symfony/psr-http-message-bridge": "^2.1", + "symfony/var-dumper": "^5.1" + }, + "suggest": { + "hyperf/pimple": "其它/无框架下使用 SDK,请安装,任选其一", + "illuminate/container": "其它/无框架下使用 SDK,请安装,任选其一" + }, + "type": "library", + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Yansongda\\Artful\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "yansongda", + "email": "me@yansongda.cn" + } + ], + "description": "Artful 是一个简单易用的 API 请求框架 PHP Api RequesT Framwork U Like。", + "keywords": [ + "api", + "artful", + "framework", + "request" + ], + "support": { + "homepage": "https://artful.yansongda.cn", + "issues": "https://github.com/yansongda/artful/issues", + "source": "https://github.com/yansongda/artful" + }, + "time": "2025-07-24T09:39:17+00:00" + }, + { + "name": "yansongda/pay", + "version": "v3.7.19", + "source": { + "type": "git", + "url": "https://github.com/yansongda/pay.git", + "reference": "57eaeff84bd4a19c4d09656a3c45250c9a032aa2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/yansongda/pay/zipball/57eaeff84bd4a19c4d09656a3c45250c9a032aa2", + "reference": "57eaeff84bd4a19c4d09656a3c45250c9a032aa2", + "shasum": "" + }, + "require": { + "ext-bcmath": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-openssl": "*", + "ext-simplexml": "*", + "php": ">=8.0", + "yansongda/artful": "~1.1.3", + "yansongda/supports": "~4.0.10" + }, + "conflict": { + "hyperf/framework": "<3.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.44", + "guzzlehttp/guzzle": "^7.0", + "hyperf/pimple": "^2.2", + "jetbrains/phpstorm-attributes": "^1.1", + "mockery/mockery": "^1.4", + "monolog/monolog": "^2.2", + "phpstan/phpstan": "^1.0.0 || ^2.0.0", + "phpunit/phpunit": "^9.0", + "symfony/event-dispatcher": "^5.2.0", + "symfony/http-foundation": "^5.2.0", + "symfony/psr-http-message-bridge": "^2.1", + "symfony/var-dumper": "^5.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Yansongda\\Pay\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "yansongda", + "email": "me@yansongda.cn" + } + ], + "description": "可能是我用过的最优雅的 Alipay 和 WeChat 的支付 SDK 扩展包了", + "keywords": [ + "alipay", + "pay", + "wechat" + ], + "support": { + "homepage": "https://pay.yansongda.cn", + "issues": "https://github.com/yansongda/pay/issues", + "source": "https://github.com/yansongda/pay" + }, + "time": "2025-12-22T03:30:53+00:00" + }, + { + "name": "yansongda/supports", + "version": "v4.0.12", + "source": { + "type": "git", + "url": "https://github.com/yansongda/supports.git", + "reference": "308de376d20cb1cd4f959644793e0582ccd1ef6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/yansongda/supports/zipball/308de376d20cb1cd4f959644793e0582ccd1ef6d", + "reference": "308de376d20cb1cd4f959644793e0582ccd1ef6d", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-mbstring": "*", + "php": ">=8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "mockery/mockery": "^1.4", + "phpstan/phpstan": "^1.1.0", + "phpunit/phpunit": "^9.0" + }, + "suggest": { + "monolog/monolog": "Use logger", + "symfony/console": "Use stdout logger" + }, + "type": "library", + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Yansongda\\Supports\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "yansongda", + "email": "me@yansongda.cn" + } + ], + "description": "common components", + "keywords": [ + "array", + "collection", + "config", + "support" + ], + "support": { + "issues": "https://github.com/yansongda/supports/issues", + "source": "https://github.com/yansongda/supports" + }, + "time": "2025-01-08T08:55:20+00:00" } ], "packages-dev": [], diff --git a/database/dev_seed.sql b/database/dev_seed.sql index 57ffed9..f009ec9 100644 --- a/database/dev_seed.sql +++ b/database/dev_seed.sql @@ -42,7 +42,9 @@ ON DUPLICATE KEY UPDATE -- 5) 插件注册表(按项目约定:app\\common\\payment\\{Code}Payment) INSERT INTO `ma_pay_plugin` (`plugin_code`, `plugin_name`, `class_name`, `status`, `created_at`, `updated_at`) -VALUES ('lakala', '拉卡拉(示例)', 'app\\\\common\\\\payment\\\\LakalaPayment', 1, NOW(), NOW()) +VALUES + ('lakala', '拉卡拉(示例)', 'app\\\\common\\\\payment\\\\LakalaPayment', 1, NOW(), NOW()), + ('alipay', '支付宝直连', 'app\\\\common\\\\payment\\\\AlipayPayment', 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE `plugin_name` = VALUES(`plugin_name`), `class_name` = VALUES(`class_name`), diff --git a/support/helpers.php b/support/helpers.php index 7cd1c16..f011f1b 100644 --- a/support/helpers.php +++ b/support/helpers.php @@ -36,3 +36,24 @@ if (!function_exists('container_make')) { return Container::make($name, $parameters); } } + +if (!function_exists('sys_config')) { + /** + * 获取系统配置项(带默认值) + * + * 读取来源:ma_system_config 表,通过 SystemConfigService + 缓存。 + * + * @param string $key 配置键名(config_key) + * @param mixed $default 默认值(未配置或为 null 时返回) + * @return mixed + * + * @example + * $siteName = sys_config('site_name', 'MyPay'); + */ + function sys_config(string $key, mixed $default = null): mixed + { + /** @var \app\services\SystemConfigService $service */ + $service = Container::get(\app\services\SystemConfigService::class); + return $service->getValue($key, $default); + } +}