1. 完善易支付API调用全流程

2. 确定支付插件继承基础类和接口规范
3. 引入Yansongda\Pay支付快捷工具
4. 重新整理代码和功能结构
This commit is contained in:
技术老胡
2026-03-12 19:18:21 +08:00
parent 5dae6e7174
commit fa3abdcaff
19 changed files with 1042 additions and 578 deletions

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace app\common\base;
use app\common\contracts\PayPluginInterface;
use app\exceptions\PaymentException;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Http\Message\ResponseInterface;
use support\Log;
/**
* 支付插件基类(建议所有插件继承)
*
* 目标:把“插件共性”集中在这里,具体渠道差异留给子类实现 `PaymentInterface`。
*
* 生命周期:
* - 服务层会在每次动作前调用 `init($channelConfig)` 注入该通道配置。
* - 子类可在 `init()` 中配置第三方 SDK例如 yansongda/pay或读取必填参数。
*
* 约定:
* - 这里的 `$channelConfig` 来源通常是 `ma_pay_channel.config_json`,属于“通道级配置”。
* - 业务级入参(如订单号、金额、回调地址等)不要混进 `$channelConfig`,应从 `pay()` 的 `$order` 参数获取。
*/
abstract class BasePayment implements PayPluginInterface
{
/**
* 插件元信息(子类必须覆盖)
*
* 常用字段:
* - code/name后台展示与标识
* - pay_types声明支持的支付方式编码如 alipay/wechat
* - config_schema后台配置表单结构fields...
* - 包含code, name, author, link, pay_types, transfer_types, config_schema 等
*
* @var array<string, mixed>
*/
protected array $paymentInfo = [];
/**
* 通道配置(由 init 注入)
*
* 建议是“纯配置”:商户号/密钥/网关地址/产品开关等。
*
* @var array<string, mixed>
*/
protected array $channelConfig = [];
/** HTTP 请求客户端GuzzleHttp */
private ?Client $httpClient = null;
// ==================== 初始化 ====================
/**
* 初始化插件,加载通道配置并创建 HTTP 客户端
*
* @param array<string, mixed> $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<string> 支付方式代码数组,如 ['alipay', 'wechat']
*/
public function getEnabledPayTypes(): array
{
return $this->paymentInfo['pay_types'] ?? [];
}
/**
* 获取插件支持的转账方式列表
*
* @return array<string> 转账方式代码数组
*/
public function getEnabledTransferTypes(): array
{
return $this->paymentInfo['transfer_types'] ?? [];
}
/**
* 获取插件配置表单结构(用于后台配置界面)
*
* @return array<string, mixed> 表单字段定义数组
*/
public function getConfigSchema(): array
{
return $this->paymentInfo['config_schema'] ?? [];
}
// ==================== HTTP 请求 ====================
/**
* 统一 HTTP 请求(对外调用支付渠道 API
*
* @param string $method 请求方法GET/POST/PUT/DELETE 等)
* @param string $url 请求地址
* @param array<string, mixed> $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]);
}
}
}

View File

@@ -1,229 +0,0 @@
<?php
namespace app\common\contracts;
use app\common\contracts\PayPluginInterface;
use support\Log;
use Webman\Http\Request;
/**
* 支付插件抽象基类
*
* 提供通用的环境检测、HTTP请求、日志记录等功能
*/
abstract class AbstractPayPlugin implements PayPluginInterface
{
/**
* 环境常量
*/
const ENV_PC = 'PC';
const ENV_H5 = 'H5';
const ENV_WECHAT = 'WECHAT';
const ENV_ALIPAY_CLIENT = 'ALIPAY_CLIENT';
/**
* 当前支付方式
*/
protected string $currentMethod = '';
/**
* 当前通道配置
*/
protected array $currentConfig = [];
/**
* 初始化插件(切换到指定支付方式)
*/
public function init(string $methodCode, array $channelConfig): void
{
if (!in_array($methodCode, static::getSupportedMethods())) {
throw new \RuntimeException("插件不支持支付方式:{$methodCode}");
}
$this->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);
}
}

View File

@@ -1,103 +1,49 @@
<?php
declare(strict_types=1);
namespace app\common\contracts;
/**
* 支付插件接口
*
* 所有支付插件必须实现此接口
* 支付插件“基础契约”接口
*
* 职责边界:
* - `PayPluginInterface`:插件生命周期 + 元信息(后台可展示/可配置/可路由)。
* - `PaymentInterface`:支付动作能力(下单/查询/关单/退款/回调)。
*
* 约定:
* - `init()` 会在每次发起支付/退款等动作前由服务层调用,用于注入该通道的 `config_json`。
* - 元信息方法应为“纯读取”,不要依赖外部状态或数据库。
*/
interface PayPluginInterface
{
/**
* 获取插件代码(唯一标识
*
* @return string
* 初始化插件(注入通道配置
*
* 典型来源:`ma_pay_channel.config_json`(以及服务层合并的 enabled_products 等)。
* 插件应在这里完成:缓存配置、初始化 SDK/HTTP 客户端等。
*
* @param array<string, mixed> $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<string>
*/
public static function getName(): string;
public function getEnabledPayTypes(): array;
/**
* 获取插件支持的支付方式列表
*
* @return array<string> 支付方式代码数组,如 ['alipay', 'wechat']
* 插件配置结构(用于后台渲染表单/校验)
*
* @return array<string, mixed>
*/
public static function getSupportedMethods(): array;
/**
* 获取指定支付方式支持的产品列表
*
* @param string $methodCode 支付方式代码
* @return array<string, string> 产品代码 => 产品名称
*/
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;
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace app\common\contracts;
use app\exceptions\PaymentException;
use support\Request;
/**
* 支付插件接口
*
* 所有支付插件必须实现此接口,用于统一下单、订单查询、关闭、退款及回调通知等核心能力。
* 建议继承 BasePayment 获得 HTTP 请求等通用能力,再实现本接口。
*
* 异常约定:实现类在业务失败时应抛出 PaymentException便于统一处理和返回。
*/
interface PaymentInterface
{
// ==================== 订单操作 ====================
/**
* 统一下单
*
* @param array<string, mixed> $order 订单数据,通常包含:
* - order_id: 系统订单号
* - mch_no: 商户号
* - amount: 金额(元)
* - subject: 商品标题
* - body: 商品描述
* @return array<string, mixed> 支付参数,需包含 pay_params、chan_order_no、chan_trade_no
* @throws PaymentException 下单失败、渠道异常、参数错误等
*/
public function pay(array $order): array;
/**
* 查询订单状态
*
* @param array<string, mixed> $order 订单数据(至少含 order_id、chan_order_no
* @return array<string, mixed> 订单状态信息,通常包含:
* - status: 订单状态
* - chan_trade_no: 渠道交易号
* - pay_amount: 实付金额
* @throws PaymentException 查询失败、渠道异常等
*/
public function query(array $order): array;
/**
* 关闭订单
*
* @param array<string, mixed> $order 订单数据(至少含 order_id、chan_order_no
* @return array<string, mixed> 关闭结果,通常包含 success、msg
* @throws PaymentException 关闭失败、渠道异常等
*/
public function close(array $order): array;
/**
* 申请退款
*
* @param array<string, mixed> $order 退款数据,通常包含:
* - order_id: 原订单号
* - chan_order_no: 渠道订单号
* - refund_amount: 退款金额
* - refund_no: 退款单号
* @return array<string, mixed> 退款结果,通常包含 success、chan_refund_no、msg
* @throws PaymentException 退款失败、渠道异常等
*/
public function refund(array $order): array;
// ==================== 异步通知 ====================
/**
* 解析并验证支付回调通知
*
* @param Request $request 支付渠道的异步通知请求GET/POST 参数)
* @return array<string, mixed> 解析结果,通常包含:
* - status: 支付状态
* - pay_order_id: 系统订单号
* - chan_trade_no: 渠道交易号
* - amount: 支付金额
* @throws PaymentException 验签失败、数据异常等
*/
public function notify(Request $request): array;
}

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace app\common\payment;
use app\common\base\BasePayment;
use app\common\contracts\PaymentInterface;
use app\exceptions\PaymentException;
use Psr\Http\Message\ResponseInterface;
use support\Request;
use Yansongda\Pay\Pay;
use Yansongda\Supports\Collection;
/**
* 支付宝支付插件(基于 yansongda/pay ~3.7
*
* 支持web电脑网站、h5手机网站、scan扫码、appAPP 支付)
*
* 通道配置app_id, app_secret_cert, app_public_cert_path, alipay_public_cert_path,
* alipay_root_cert_path, notify_url, return_url, mode(0正式/1沙箱)
*/
class AlipayPayment extends BasePayment implements PaymentInterface
{
protected array $paymentInfo = [
'code' => '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);
}
}
}

View File

@@ -1,180 +1,78 @@
<?php
declare(strict_types=1);
namespace app\common\payment;
use app\common\contracts\AbstractPayPlugin;
use app\common\base\BasePayment;
use app\common\contracts\PaymentInterface;
use app\exceptions\PaymentException;
use support\Request;
/**
* 拉卡拉支付插件示例
* 拉卡拉支付插件(最小可用示例
*
* 支持多个支付方式alipay、wechat、unionpay
* 目的:先把 API 下单链路跑通,让现有 DB 配置ma_pay_plugin=lakala可用。
* 后续你可以把这里替换为真实拉卡拉对接逻辑HTTP 下单、验签回调等)。
*/
class LakalaPayment extends AbstractPayPlugin
class LakalaPayment extends BasePayment implements PaymentInterface
{
public static function getCode(): string
{
return 'lakala';
}
public static function getName(): string
{
return '拉卡拉支付';
}
/**
* 支持多个支付方式
*/
public static function getSupportedMethods(): array
{
return ['alipay', 'wechat', 'unionpay'];
}
/**
* 根据支付方式返回支持的产品
*/
public static function getSupportedProducts(string $methodCode): array
{
return match ($methodCode) {
'alipay' => [
['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);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace app\common\utils;
/**
* 易支付签名工具MD5
*
* 规则:
* - 排除 sign、sign_type
* - 排除空值null / ''
* - 按字段名 ASCII 升序排序
* - k=v&...&key=app_secret
* - MD5 后转小写(兼容大小写比较)
*/
final class EpayUtil
{
/**
* 生成签名字符串
*
* @param array<string, mixed> $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<string, mixed> $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);
}
}