1. 维护代码健壮

2. 更新项目结构文档
This commit is contained in:
技术老胡
2026-04-27 16:20:41 +08:00
parent 9a16a88640
commit 0e5de50337
198 changed files with 21038 additions and 702 deletions

View File

@@ -18,7 +18,7 @@ use support\Log;
*
* 生命周期:
* - 服务层会在每次动作前调用 `init($channelConfig)` 注入该通道配置。
* - 子类可在 `init()` 中配置第三方 SDK(例如 yansongda/pay或读取必填参数。
* - 子类可在 `init()` 中配置第三方 SDK 或读取必填参数。
*
* 约定:
* - 这里的 `$channelConfig` 来源通常是 `ma_payment_plugin_conf.config`,并附带通道维度上下文。
@@ -191,7 +191,11 @@ abstract class BasePayment implements PayPluginInterface
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]);
throw new PaymentException('渠道请求失败', 402, [
'method' => $method,
'url' => $url,
'error' => $e->getMessage(),
]);
}
}
}

View File

@@ -5,50 +5,70 @@ namespace app\common\constant;
/**
* 认证相关常量。
*
* 统一管理登录域、令牌状态、签名类型等枚举值
* 统一管理登录域、会话状态、接口凭证状态和开放接口签名类型。
*/
final class AuthConstant
{
/**
* 管理员登录域。
*/
public const GUARD_ADMIN = 1;
public const GUARD_ADMIN = 'admin';
/**
* 商户登录域。
*/
public const GUARD_MERCHANT = 2;
public const GUARD_MERCHANT = 'merchant';
/**
* JWT 签名算法。
*/
public const JWT_ALG_HS256 = 'HS256';
public const JWT_ALGORITHM_HS256 = 'HS256';
/**
* 令牌禁用状态。
* 会话令牌禁用状态。
*/
public const TOKEN_STATUS_DISABLED = 0;
/**
* 令牌启用状态。
* 会话令牌启用状态。
*/
public const TOKEN_STATUS_ENABLED = 1;
/**
* 登录禁用状态。
* 接口凭证禁用状态。
*/
public const LOGIN_STATUS_DISABLED = 0;
public const CREDENTIAL_STATUS_DISABLED = 0;
/**
* 登录启用状态。
* 接口凭证启用状态。
*/
public const LOGIN_STATUS_ENABLED = 1;
public const CREDENTIAL_STATUS_ENABLED = 1;
/**
* API 签名类型MD5。
*/
public const API_SIGN_TYPE_MD5 = 0;
/**
* API 签名类型SHA256WithRSA。
*/
public const API_SIGN_TYPE_SHA256_WITH_RSA = 1;
/**
* API 签名类型名称MD5。
*/
public const API_SIGN_NAME_MD5 = 'MD5';
/**
* API 签名类型名称SHA256WithRSA。
*/
public const API_SIGN_NAME_SHA256_WITH_RSA = 'SHA256WithRSA';
/**
* API 签名类型归一化名称SHA256WITHRSA。
*/
public const API_SIGN_NORMALIZED_SHA256_WITH_RSA = 'SHA256WITHRSA';
/**
* 获取签名类型映射。
*
@@ -57,24 +77,36 @@ final class AuthConstant
public static function signTypeMap(): array
{
return [
self::API_SIGN_TYPE_MD5 => 'MD5',
self::API_SIGN_TYPE_MD5 => self::API_SIGN_NAME_MD5,
self::API_SIGN_TYPE_SHA256_WITH_RSA => self::API_SIGN_NAME_SHA256_WITH_RSA,
];
}
/**
* 获取接口凭证状态映射。
*
* @return array<int, string> 接口凭证状态名称表
*/
public static function credentialStatusMap(): array
{
return [
self::CREDENTIAL_STATUS_ENABLED => '启用',
self::CREDENTIAL_STATUS_DISABLED => '禁用',
];
}
/**
* 获取登录域映射。
*
* @return array<int, string> 登录域名称表
* @return array<string, string> 登录域名称表
*/
public static function guardMap(): array
{
return [
self::GUARD_ADMIN => 'admin',
self::GUARD_MERCHANT => 'merchant',
self::GUARD_ADMIN => '管理后台',
self::GUARD_MERCHANT => '商户后台',
];
}
}

View File

@@ -18,12 +18,12 @@ final class CommonConstant
public const STATUS_ENABLED = 1;
/**
* 否。
* 否,通常用于布尔类字段的数值表示
*/
public const NO = 0;
/**
* 是。
* 是,通常用于布尔类字段的数值表示
*/
public const YES = 1;
@@ -56,4 +56,3 @@ final class CommonConstant

View File

@@ -0,0 +1,67 @@
<?php
namespace app\common\constant;
/**
* 事件名称常量。
*
* 本类维护已定义的领域事件;事件是否实际启用处理器,以 config/event.php 注册为准。
* 生命周期服务可以先 dispatch 关键节点事件,后续需要告警、风控、统计或补偿时再注册 listener。
*/
final class EventConstant
{
/**
* 系统配置已变更。
*/
public const SYSTEM_CONFIG_CHANGED = 'system.config.changed';
/**
* 支付单首次进入成功态。
*/
public const PAYMENT_PAY_ORDER_SUCCEEDED = 'payment.pay_order.succeeded';
/**
* 支付单进入失败态。
*/
public const PAYMENT_PAY_ORDER_FAILED = 'payment.pay_order.failed';
/**
* 支付单进入关闭态。
*/
public const PAYMENT_PAY_ORDER_CLOSED = 'payment.pay_order.closed';
/**
* 支付单进入超时态。
*/
public const PAYMENT_PAY_ORDER_TIMEOUT = 'payment.pay_order.timeout';
/**
* 退款单进入成功态。
*/
public const REFUND_ORDER_SUCCEEDED = 'payment.refund_order.succeeded';
/**
* 退款单进入失败态。
*/
public const REFUND_ORDER_FAILED = 'payment.refund_order.failed';
/**
* 清算单进入成功态。
*/
public const SETTLEMENT_ORDER_SUCCEEDED = 'payment.settlement_order.succeeded';
/**
* 清算单进入失败态。
*/
public const SETTLEMENT_ORDER_FAILED = 'payment.settlement_order.failed';
/**
* 商户通知派发成功。
*/
public const MERCHANT_NOTIFY_SUCCEEDED = 'payment.merchant_notify.succeeded';
/**
* 商户通知派发失败。
*/
public const MERCHANT_NOTIFY_FAILED = 'payment.merchant_notify.failed';
}

View File

@@ -69,23 +69,77 @@ final class FileConstant
*/
public const STORAGE_REMOTE_URL = 4;
/**
* 文件存储默认引擎配置 key。
*/
public const CONFIG_DEFAULT_ENGINE = 'file_storage_default_engine';
/**
* 本地公开目录访问地址配置 key。
*/
public const CONFIG_LOCAL_PUBLIC_BASE_URL = 'file_storage_local_public_base_url';
/**
* 本地公开目录路径配置 key。
*/
public const CONFIG_LOCAL_PUBLIC_DIR = 'file_storage_local_public_dir';
/**
* 本地私有目录路径配置 key。
*/
public const CONFIG_LOCAL_PRIVATE_DIR = 'file_storage_local_private_dir';
/**
* 上传文件大小上限配置 key单位 MB。
*/
public const CONFIG_UPLOAD_MAX_SIZE_MB = 'file_storage_upload_max_size_mb';
/**
* 远程下载大小上限配置 key单位 MB。
*/
public const CONFIG_REMOTE_DOWNLOAD_LIMIT_MB = 'file_storage_remote_download_limit_mb';
/**
* 允许上传扩展名配置 key。
*/
public const CONFIG_ALLOWED_EXTENSIONS = 'file_storage_allowed_extensions';
/**
* 阿里云 OSS Endpoint 配置 key。
*/
public const CONFIG_OSS_ENDPOINT = 'file_storage_aliyun_oss_endpoint';
/**
* 阿里云 OSS Bucket 配置 key。
*/
public const CONFIG_OSS_BUCKET = 'file_storage_aliyun_oss_bucket';
/**
* 阿里云 OSS Access Key ID 配置 key。
*/
public const CONFIG_OSS_ACCESS_KEY_ID = 'file_storage_aliyun_oss_access_key_id';
/**
* 阿里云 OSS Access Key Secret 配置 key。
*/
public const CONFIG_OSS_ACCESS_KEY_SECRET = 'file_storage_aliyun_oss_access_key_secret';
/**
* 阿里云 OSS 公开域名配置 key。
*/
public const CONFIG_OSS_PUBLIC_DOMAIN = 'file_storage_aliyun_oss_public_domain';
/**
* 阿里云 OSS 地域配置 key。
*/
public const CONFIG_OSS_REGION = 'file_storage_aliyun_oss_region';
/**
* 腾讯云 COS 地域配置 key。
*/
public const CONFIG_COS_REGION = 'file_storage_tencent_cos_region';
/**
* 腾讯云 COS Bucket 配置 key。
*/
public const CONFIG_COS_BUCKET = 'file_storage_tencent_cos_bucket';
/**
* 腾讯云 COS SecretId 配置 key。
*/
public const CONFIG_COS_SECRET_ID = 'file_storage_tencent_cos_secret_id';
/**
* 腾讯云 COS SecretKey 配置 key。
*/
public const CONFIG_COS_SECRET_KEY = 'file_storage_tencent_cos_secret_key';
/**
* 腾讯云 COS 公开域名配置 key。
*/
public const CONFIG_COS_PUBLIC_DOMAIN = 'file_storage_tencent_cos_public_domain';
/**
@@ -227,4 +281,3 @@ final class FileConstant

View File

@@ -7,19 +7,67 @@ namespace app\common\constant;
*/
final class LedgerConstant
{
/**
* 支付冻结流水。
*/
public const BIZ_TYPE_PAY_FREEZE = 0;
/**
* 支付扣费流水。
*/
public const BIZ_TYPE_PAY_DEDUCT = 1;
/**
* 支付释放流水。
*/
public const BIZ_TYPE_PAY_RELEASE = 2;
/**
* 清算入账流水。
*/
public const BIZ_TYPE_SETTLEMENT_CREDIT = 3;
/**
* 退款冲正流水。
*/
public const BIZ_TYPE_REFUND_REVERSE = 4;
/**
* 人工调整流水。
*/
public const BIZ_TYPE_MANUAL_ADJUST = 5;
/**
* 转账扣款流水。
*/
public const BIZ_TYPE_TRANSFER_DEDUCT = 6;
/**
* 转账手续费流水。
*/
public const BIZ_TYPE_TRANSFER_FEE = 7;
/**
* 转账释放流水。
*/
public const BIZ_TYPE_TRANSFER_RELEASE = 8;
/**
* 账务事件的创建动作。
*/
public const EVENT_TYPE_CREATE = 0;
/**
* 账务事件的成功动作。
*/
public const EVENT_TYPE_SUCCESS = 1;
/**
* 账务事件的失败动作。
*/
public const EVENT_TYPE_FAILED = 2;
/**
* 账务事件的冲正动作。
*/
public const EVENT_TYPE_REVERSE = 3;
/**
* 流水入账方向。
*/
public const DIRECTION_IN = 0;
/**
* 流水出账方向。
*/
public const DIRECTION_OUT = 1;
/**
@@ -36,6 +84,9 @@ final class LedgerConstant
self::BIZ_TYPE_SETTLEMENT_CREDIT => '清算入账',
self::BIZ_TYPE_REFUND_REVERSE => '退款冲正',
self::BIZ_TYPE_MANUAL_ADJUST => '人工调整',
self::BIZ_TYPE_TRANSFER_DEDUCT => '转账扣款',
self::BIZ_TYPE_TRANSFER_FEE => '转账手续费',
self::BIZ_TYPE_TRANSFER_RELEASE => '转账释放',
];
}
@@ -69,5 +120,3 @@ final class LedgerConstant
}

View File

@@ -68,4 +68,3 @@ final class MerchantConstant

View File

@@ -7,22 +7,74 @@ namespace app\common\constant;
*/
final class NotifyConstant
{
/**
* 商户通知事件:支付成功。
*/
public const EVENT_PAY_SUCCESS = 'PAY_SUCCESS';
/**
* 商户通知事件:退款成功。
*/
public const EVENT_REFUND_SUCCESS = 'REFUND_SUCCESS';
/**
* 商户通知事件:清算完成。
*/
public const EVENT_SETTLEMENT_SUCCESS = 'SETTLEMENT_SUCCESS';
/**
* 异步通知类型。
*/
public const NOTIFY_TYPE_ASYNC = 0;
/**
* 查单通知类型。
*/
public const NOTIFY_TYPE_QUERY = 1;
/**
* 异步回调类型。
*/
public const CALLBACK_TYPE_ASYNC = 0;
/**
* 同步回调类型。
*/
public const CALLBACK_TYPE_SYNC = 1;
/**
* 验证状态:未知。
*/
public const VERIFY_STATUS_UNKNOWN = 0;
/**
* 验证状态:成功。
*/
public const VERIFY_STATUS_SUCCESS = 1;
/**
* 验证状态:失败。
*/
public const VERIFY_STATUS_FAILED = 2;
/**
* 处理状态:待处理。
*/
public const PROCESS_STATUS_PENDING = 0;
/**
* 处理状态:成功。
*/
public const PROCESS_STATUS_SUCCESS = 1;
/**
* 处理状态:失败。
*/
public const PROCESS_STATUS_FAILED = 2;
/**
* 任务状态:待通知。
*/
public const TASK_STATUS_PENDING = 0;
/**
* 任务状态:成功。
*/
public const TASK_STATUS_SUCCESS = 1;
/**
* 任务状态:失败。
*/
public const TASK_STATUS_FAILED = 2;
/**
@@ -92,8 +144,20 @@ final class NotifyConstant
self::TASK_STATUS_FAILED => '失败',
];
}
/**
* 获取商户通知事件映射。
*
* @return array<string, string> 商户通知事件名称表
*/
public static function eventTypeMap(): array
{
return [
self::EVENT_PAY_SUCCESS => '支付成功',
self::EVENT_REFUND_SUCCESS => '退款成功',
self::EVENT_SETTLEMENT_SUCCESS => '清算完成',
];
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace app\common\constant;
/**
* 支付插件协议状态常量。
*
* 这些状态用于插件和平台运行时之间传递渠道结果,不等同于内部订单状态码。
*/
final class PaymentPluginStatusConstant
{
/**
* 渠道支付成功。
*/
public const SUCCESS = 'success';
/**
* 渠道支付失败。
*/
public const FAILED = 'failed';
/**
* 渠道仍在处理中。
*/
public const PENDING = 'pending';
/**
* 渠道订单已关闭。
*/
public const CLOSED = 'closed';
/**
* 渠道状态未知。
*/
public const UNKNOWN = 'unknown';
/**
* 插件回调允许返回的状态。
*
* @return array<int, string>
*/
public static function notifyStatuses(): array
{
return [
self::SUCCESS,
self::FAILED,
self::PENDING,
];
}
/**
* 插件查单成功状态别名。
*
* @return array<int, string>
*/
public static function successQueryAliases(): array
{
return [
self::SUCCESS,
'paid',
'pay_success',
'trade_success',
'trade_finished',
'finished',
'successful',
];
}
/**
* 插件查单失败状态别名。
*
* @return array<int, string>
*/
public static function failedQueryAliases(): array
{
return [
self::FAILED,
'fail',
'error',
'pay_error',
'trade_fail',
];
}
/**
* 插件查单关闭状态别名。
*
* @return array<int, string>
*/
public static function closedQueryAliases(): array
{
return [
self::CLOSED,
'close',
'trade_closed',
];
}
}

View File

@@ -20,12 +20,12 @@ final class RouteConstant
public const CHANNEL_TYPE_MERCHANT_SELF = 1;
/**
* 代收通道模式。
* 代收通道模式,资金直接进入平台侧
*/
public const CHANNEL_MODE_COLLECT = 0;
/**
* 自收通道模式。
* 自收通道模式,资金直接进入商户侧
*/
public const CHANNEL_MODE_SELF = 1;
@@ -87,4 +87,3 @@ final class RouteConstant

View File

@@ -7,33 +7,105 @@ namespace app\common\constant;
*/
final class TradeConstant
{
/**
* D0 清算周期。
*/
public const SETTLEMENT_CYCLE_D0 = 0;
/**
* D1 清算周期。
*/
public const SETTLEMENT_CYCLE_D1 = 1;
/**
* D7 清算周期。
*/
public const SETTLEMENT_CYCLE_D7 = 2;
/**
* T1 清算周期。
*/
public const SETTLEMENT_CYCLE_T1 = 3;
/**
* 其他清算周期。
*/
public const SETTLEMENT_CYCLE_OTHER = 4;
/**
* 订单已创建,等待发起支付。
*/
public const ORDER_STATUS_CREATED = 0;
/**
* 订单支付中。
*/
public const ORDER_STATUS_PAYING = 1;
/**
* 订单支付成功。
*/
public const ORDER_STATUS_SUCCESS = 2;
/**
* 订单支付失败。
*/
public const ORDER_STATUS_FAILED = 3;
/**
* 订单已关闭。
*/
public const ORDER_STATUS_CLOSED = 4;
/**
* 订单已超时。
*/
public const ORDER_STATUS_TIMEOUT = 5;
/**
* 手续费未处理。
*/
public const FEE_STATUS_NONE = 0;
/**
* 手续费已冻结。
*/
public const FEE_STATUS_FROZEN = 1;
/**
* 手续费已扣除。
*/
public const FEE_STATUS_DEDUCTED = 2;
/**
* 手续费已释放。
*/
public const FEE_STATUS_RELEASED = 3;
/**
* 清算状态为空。
*/
public const SETTLEMENT_STATUS_NONE = 0;
/**
* 清算待处理。
*/
public const SETTLEMENT_STATUS_PENDING = 1;
/**
* 清算已完成。
*/
public const SETTLEMENT_STATUS_SETTLED = 2;
/**
* 清算已冲正。
*/
public const SETTLEMENT_STATUS_REVERSED = 3;
/**
* 退款单已创建。
*/
public const REFUND_STATUS_CREATED = 0;
/**
* 退款单处理中。
*/
public const REFUND_STATUS_PROCESSING = 1;
/**
* 退款单成功。
*/
public const REFUND_STATUS_SUCCESS = 2;
/**
* 退款单失败。
*/
public const REFUND_STATUS_FAILED = 3;
/**
* 退款单已关闭。
*/
public const REFUND_STATUS_CLOSED = 4;
/**
@@ -231,4 +303,3 @@ final class TradeConstant

View File

@@ -0,0 +1,36 @@
<?php
namespace app\common\constant;
/**
* 转账状态枚举。
*/
final class TransferConstant
{
/**
* 转账待处理状态。
*/
public const TRANSFER_STATUS_PENDING = 0;
/**
* 转账成功状态。
*/
public const TRANSFER_STATUS_SUCCESS = 1;
/**
* 转账失败状态。
*/
public const TRANSFER_STATUS_FAILED = 2;
/**
* 获取转账状态映射。
*
* @return array<int, string>
*/
public static function transferStatusMap(): array
{
return [
self::TRANSFER_STATUS_PENDING => '待处理',
self::TRANSFER_STATUS_SUCCESS => '成功',
self::TRANSFER_STATUS_FAILED => '失败',
];
}
}

View File

@@ -23,6 +23,20 @@ interface PaymentInterface
/**
* 发起支付下单。
*
* 插件必须返回系统标准结构,服务层会严格校验后写入支付单 `ext_json.presentation`
* - `pay_product`:支付产品或上游支付方式,例如 `alipay`、`wxpay`、`alipay_h5`
* - `pay_action`:支付动作,例如 `jump`、`qrcode`、`html`、`jsapi`
* - `pay_params.type`:收银台承接类型,支持 `jump`、`web`、`h5`、`qrcode`、`html`、`jsapi`、`urlscheme`、`mini`、`pos`、`transfer`、`json`、`error`
* - `chan_order_no`:渠道订单号,必须返回
* - `chan_trade_no`:渠道交易号,可选;未生成时返回空字符串
* - `ext_json`:插件私有轻量信息,可选;原始响应不要塞入支付单扩展
*
* `pay_params` 必须带上对应 `type` 的必要载荷:
* - 跳转类:`redirect_url` / `payurl` / `mweb_url`
* - 二维码类:`qrcode_text` / `qrcode_data` / `qrcode_url`
* - 表单类:`html` 或 `action`
* - JSAPI / URL Scheme / 小程序:对应拉起参数或跳转参数
*
* @param array $order 订单参数
* @return array 下单结果
*/
@@ -31,6 +45,15 @@ interface PaymentInterface
/**
* 查询订单状态。
*
* 建议返回当前系统标准结构,定时维护进程会按 `status` 推进支付单:
* - `success=true|false`:查询请求是否成功;查询失败不等于支付失败
* - `status``success` / `failed` / `closed` / `pending`
* - `channel_order_no` / `channel_trade_no`:渠道单号
* - `channel_status`:渠道原始状态,可选
* - `message`:查询说明,可选
* - `paid_at` / `failed_at`:终态时间,可选
* - `ext_json`:插件私有轻量补充信息,可选
*
* @param array $order 订单参数
* @return array 查询结果
*/
@@ -57,8 +80,24 @@ interface PaymentInterface
/**
* 解析并验证支付回调通知。
*
* 插件应返回当前系统统一可消费的结果结构,核心字段如下:
* - `status`:支付状态,限定为 `success` / `failed` / `pending`
* - `channel_order_no` / `channel_trade_no`:渠道单号,必须返回
* - `channel_status`:渠道原始状态码或状态文本,可选
* - `message`:回调处理说明,可选
* - `channel_error_code` / `channel_error_msg`:渠道失败原因,可选
* - `paid_at` / `failed_at`:支付成功或失败时间,可选
* - `fee_actual_amount`:实际手续费,单位分,可选
* - `ext_json`:插件私有的轻量补充信息,可选;原始回调和解析结果会进入回调日志,不要塞进支付单扩展
*
* 插件在验签失败、报文非法或关键字段缺失时,应直接抛出 `PaymentException`。
* 只有在回调可信时,才返回标准结果数组。
* 如果第三方渠道只返回了一个唯一订单号,插件应同时填充 `channel_order_no` 和 `channel_trade_no`
* 两个字段可以写成相同值。
* 业务上尚未终态时返回 `status=pending`,由系统统一记录回调日志而不推进支付单终态。
*
* @param Request $request 请求对象
* @return array 回调结果
* @return array<string, mixed> 回调结果
*/
public function notify(Request $request): array;

View File

@@ -6,6 +6,7 @@ namespace app\common\payment;
use app\common\base\BasePayment;
use app\common\constant\FileConstant;
use app\common\constant\PaymentPluginStatusConstant;
use app\common\interface\PaymentInterface;
use app\common\interface\PayPluginInterface;
use app\common\util\FormatHelper;
@@ -324,6 +325,11 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
$extra = isset($order['extra']) && is_array($order['extra']) ? $order['extra'] : [];
if ($extra !== []) {
$context = array_merge($context, $extra);
foreach (['merchant', 'payment', 'source'] as $section) {
if (isset($extra[$section]) && is_array($extra[$section])) {
$context = array_merge($context, $extra[$section]);
}
}
}
$param = $this->normalizeParamBag($context['param'] ?? null);
@@ -459,7 +465,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
} catch (PaymentException $e) {
throw $e;
} catch (\Throwable $e) {
throw new PaymentException('支付宝下单失败' . $e->getMessage(), 402, ['order_id' => $orderId]);
throw new PaymentException('支付宝下单失败', 402, ['order_id' => $orderId, 'error' => $e->getMessage()]);
}
}
@@ -477,7 +483,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
'pay_product' => self::PRODUCT_WEB,
'pay_action' => $this->productAction(self::PRODUCT_WEB),
'pay_params' => [
'type' => 'form',
'type' => 'html',
'method' => 'POST',
'action' => '',
'html' => $body,
@@ -507,7 +513,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
'pay_product' => self::PRODUCT_H5,
'pay_action' => $this->productAction(self::PRODUCT_H5),
'pay_params' => [
'type' => 'form',
'type' => 'html',
'method' => 'POST',
'action' => '',
'html' => $body,
@@ -733,8 +739,8 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
$tradeNo = (string) $this->extractCollectionValue($result, ['trade_no', 'order_id', 'out_biz_no'], '');
$totalAmount = (string) $this->extractCollectionValue($result, ['total_amount', 'trans_amount', 'amount'], '0');
$status = match ($action) {
'transfer' => in_array($tradeStatus, ['SUCCESS', 'PAY_SUCCESS', 'SUCCESSFUL'], true) ? 'success' : $tradeStatus,
default => in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true) ? 'success' : $tradeStatus,
'transfer' => in_array($tradeStatus, ['SUCCESS', 'PAY_SUCCESS', 'SUCCESSFUL'], true) ? PaymentPluginStatusConstant::SUCCESS : $tradeStatus,
default => in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true) ? PaymentPluginStatusConstant::SUCCESS : $tradeStatus,
};
return [
@@ -745,7 +751,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
'pay_amount' => (int) round(((float) $totalAmount) * 100),
];
} catch (\Throwable $e) {
throw new PaymentException('支付宝查询失败' . $e->getMessage(), 402);
throw new PaymentException('支付宝查询失败', 402, ['order_id' => $outTradeNo, 'error' => $e->getMessage()]);
}
}
@@ -774,7 +780,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
Pay::alipay()->close($closeParams);
return ['success' => true, 'msg' => '关闭成功', 'pay_product' => $product, 'pay_action' => $action];
} catch (\Throwable $e) {
throw new PaymentException('支付宝关单失败' . $e->getMessage(), 402);
throw new PaymentException('支付宝关单失败', 402, ['order_id' => $outTradeNo, 'error' => $e->getMessage()]);
}
}
@@ -823,11 +829,11 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
'msg' => '退款成功',
];
}
throw new PaymentException($subMsg ?: '退款失败', 402);
throw new PaymentException('退款失败', 402, ['order_id' => $outTradeNo, 'sub_msg' => $subMsg]);
} catch (PaymentException $e) {
throw $e;
} catch (\Throwable $e) {
throw new PaymentException('支付宝退款失败' . $e->getMessage(), 402);
throw new PaymentException('支付宝退款失败', 402, ['order_id' => $outTradeNo, 'error' => $e->getMessage()]);
}
}
@@ -852,22 +858,26 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
$paidAt = (string) $result->get('gmt_payment', '');
if (!in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) {
throw new PaymentException('回调状态异常' . $tradeStatus, 402);
throw new PaymentException('回调状态异常', 402, ['trade_status' => $tradeStatus]);
}
return [
'success' => true,
'status' => 'success',
'pay_order_id' => $outTradeNo,
'chan_order_no' => $outTradeNo,
'chan_trade_no' => $tradeNo,
'amount' => (int) round(((float) $totalAmount) * 100),
'paid_at' => $paidAt !== '' ? (FormatHelper::timestamp((int) strtotime($paidAt)) ?: null) : null,
'status' => PaymentPluginStatusConstant::SUCCESS,
'message' => '支付成功',
'channel_order_no' => $outTradeNo,
'channel_trade_no' => $tradeNo,
'channel_status' => $tradeStatus,
'paid_at' => $paidAt !== '' ? (FormatHelper::timestamp((int) strtotime($paidAt)) ?: null) : null,
'fee_actual_amount' => null,
'ext_json' => [
'channel_pay_amount' => (int) round(((float) $totalAmount) * 100),
'channel_response' => $result->all(),
],
];
} catch (PaymentException $e) {
throw $e;
} catch (\Throwable $e) {
throw new PaymentException('支付宝回调验签失败' . $e->getMessage(), 402);
throw new PaymentException('支付宝回调验签失败', 402, ['error' => $e->getMessage()]);
}
}
@@ -891,6 +901,3 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
return 'fail';
}
}

View File

@@ -0,0 +1,798 @@
<?php
declare(strict_types=1);
namespace app\common\payment;
use app\common\base\BasePayment;
use app\common\constant\AuthConstant;
use app\common\constant\PaymentPluginStatusConstant;
use app\common\interface\PaymentInterface;
use app\common\interface\PayPluginInterface;
use app\common\util\FormatHelper;
use app\exception\PaymentException;
use app\service\payment\epay\EpaySignerManager;
use support\Request;
use support\Response;
/**
* ePay V1 网关插件。
*
* 适用于对接仍提供 V1 协议的第三方平台。
*/
class EpayV1Payment extends BasePayment implements PaymentInterface, PayPluginInterface
{
private ?EpaySignerManager $epaySignerManager = null;
/**
* @var array<string, mixed>
*/
protected array $paymentInfo = [
'code' => 'epay_v1',
'name' => 'ePay V1 网关',
'author' => 'MPAY',
'version' => '1.0.0',
'pay_types' => ['alipay', 'wxpay'],
'transfer_types' => [],
'config_schema' => [
[
'type' => 'input',
'field' => 'gateway_url',
'title' => '上游网关地址',
'value' => '',
'props' => [
'placeholder' => '例如https://pay.example.com',
],
'validate' => [
['required' => true, 'message' => '上游网关地址不能为空'],
],
],
[
'type' => 'input',
'field' => 'upstream_pid',
'title' => '上游商户ID',
'value' => '',
'props' => [
'placeholder' => '请输入第三方平台分配的 pid',
],
'validate' => [
['required' => true, 'message' => '上游商户ID不能为空'],
],
],
[
'type' => 'textarea',
'field' => 'upstream_key',
'title' => '上游 MD5 密钥',
'value' => '',
'props' => [
'placeholder' => '请输入第三方平台分配的 API Key / KEY',
'rows' => 4,
],
'validate' => [
['required' => true, 'message' => '上游 MD5 密钥不能为空'],
],
],
[
'type' => 'input',
'field' => 'pay_path',
'title' => '下单路径',
'value' => '/mapi.php',
'props' => [
'placeholder' => '默认 /mapi.php',
],
],
[
'type' => 'input',
'field' => 'api_path',
'title' => '查询/退款路径',
'value' => '/api.php',
'props' => [
'placeholder' => '默认 /api.php',
],
],
[
'type' => 'textarea',
'field' => 'type_mapping_json',
'title' => '支付方式映射',
'value' => "{\n \"alipay\": \"alipay\",\n \"wxpay\": \"wxpay\"\n}",
'props' => [
'placeholder' => 'JSON 格式,例如 {\"wxpay\":\"wxpay\"}',
'rows' => 5,
],
],
],
];
public function init(array $channelConfig): void
{
parent::init($channelConfig);
}
public function pay(array $order): array
{
$payload = [
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
'type' => $this->resolveUpstreamType($order, [
'alipay' => 'alipay',
'wxpay' => 'wxpay',
]),
'out_trade_no' => $this->resolveOrderNo($order),
'notify_url' => trim((string) ($order['callback_url'] ?? '')),
'return_url' => trim((string) ($order['return_url'] ?? '')),
'name' => $this->resolveSubject($order),
'money' => $this->amountToMoney($this->resolveAmount($order)),
'clientip' => trim((string) ($order['client_ip'] ?? '127.0.0.1')),
'device' => $this->resolveDevice($order),
];
$param = $this->resolveParamValue($order);
if ($param !== '') {
$payload['param'] = $param;
}
$payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_MD5, $this->requireConfigValue('upstream_key', '上游 MD5 密钥'));
$response = $this->isMockEnabled()
? $this->buildMockPayResponse($payload, $order)
: $this->requestFormJson('POST', $this->resolveGatewayUrl('pay_path', '/mapi.php'), $payload);
if ((int) ($response['code'] ?? 0) !== 1) {
throw new PaymentException((string) ($response['msg'] ?? '上游 V1 下单失败'), 40200, [
'response' => $response,
]);
}
$channelNos = $this->resolveChannelNos($response + [
'trade_no' => (string) ($response['trade_no'] ?? $payload['out_trade_no']),
]);
$payParams = $this->normalizePayResponse($response);
return [
'pay_product' => (string) $payload['type'],
'pay_action' => (string) ($payParams['type'] ?? ''),
'pay_params' => $payParams,
'chan_order_no' => $channelNos['channel_order_no'],
'chan_trade_no' => $channelNos['channel_trade_no'],
];
}
public function query(array $order): array
{
$payload = [
'act' => 'order',
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
'key' => $this->requireConfigValue('upstream_key', '上游 MD5 密钥'),
];
$tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? ''));
if ($tradeNo !== '') {
$payload['trade_no'] = $tradeNo;
} else {
$payload['out_trade_no'] = $this->resolveOrderNo($order);
}
$response = $this->isMockEnabled()
? $this->buildMockQueryResponse($order)
: $this->requestQueryJson($this->resolveGatewayUrl('api_path', '/api.php'), $payload);
if ((int) ($response['code'] ?? 0) !== 1) {
return [
'success' => false,
'msg' => (string) ($response['msg'] ?? '上游 V1 查单失败'),
'raw_data' => $response,
];
}
$channelNos = $this->resolveChannelNos($response);
$status = (int) ($response['status'] ?? 0) === 1
? PaymentPluginStatusConstant::SUCCESS
: PaymentPluginStatusConstant::PENDING;
return [
'success' => true,
'status' => $status,
'channel_order_no' => $channelNos['channel_order_no'],
'channel_trade_no' => $channelNos['channel_trade_no'],
'channel_status' => (string) ($response['status'] ?? ''),
'paid_at' => $response['endtime'] ?? null,
'ext_json' => [
'channel_response' => $response,
],
];
}
public function close(array $order): array
{
throw new PaymentException('上游 ePay V1 协议不支持关单', 40200, [
'plugin_code' => $this->getCode(),
'order_no' => $this->resolveOrderNo($order),
]);
}
public function refund(array $order): array
{
$payload = [
'act' => 'refund',
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
'key' => $this->requireConfigValue('upstream_key', '上游 MD5 密钥'),
'money' => $this->amountToMoney((int) ($order['refund_amount'] ?? 0)),
];
$tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? ''));
if ($tradeNo !== '') {
$payload['trade_no'] = $tradeNo;
} else {
$payload['out_trade_no'] = $this->resolveOrderNo($order);
}
$response = $this->isMockEnabled()
? $this->buildMockRefundResponse($order)
: $this->requestFormJson('POST', $this->resolveGatewayUrl('api_path', '/api.php'), $payload);
if ((int) ($response['code'] ?? 0) !== 1) {
return [
'success' => false,
'msg' => (string) ($response['msg'] ?? '上游 V1 退款失败'),
'raw_data' => $response,
];
}
return [
'success' => true,
'msg' => (string) ($response['msg'] ?? 'success'),
'chan_refund_no' => trim((string) ($response['refund_no'] ?? $response['trade_no'] ?? '')),
'raw_data' => $response,
];
}
public function notify(Request $request): array
{
$payload = $this->resolveNotifyPayload($request);
$this->verifyPayloadSignature(
$payload,
AuthConstant::API_SIGN_NAME_MD5,
$this->requireConfigValue('upstream_key', '上游 MD5 密钥'),
'上游 V1 回调验签失败'
);
$channelNos = $this->resolveChannelNos($payload);
$status = $this->normalizeNotifyStatus((string) ($payload['trade_status'] ?? ''));
return [
'status' => $status,
'message' => (string) ($payload['trade_status'] ?? ''),
'channel_order_no' => $channelNos['channel_order_no'],
'channel_trade_no' => $channelNos['channel_trade_no'],
'channel_status' => (string) ($payload['trade_status'] ?? ''),
'paid_at' => $payload['endtime'] ?? null,
'ext_json' => [
'channel_type' => (string) ($payload['type'] ?? ''),
],
];
}
public function notifySuccess(): string|Response
{
return 'success';
}
public function notifyFail(): string|Response
{
return 'fail';
}
/**
* 获取签名管理器。
*/
private function signerManager(): EpaySignerManager
{
if ($this->epaySignerManager === null) {
/** @var EpaySignerManager $manager */
$manager = container_make(EpaySignerManager::class, []);
$this->epaySignerManager = $manager;
}
return $this->epaySignerManager;
}
/**
* 是否启用插件内置 mock。
*/
private function isMockEnabled(): bool
{
$value = $this->getConfig('mock_enabled', false);
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int) $value === 1;
}
$filtered = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
return $filtered ?? false;
}
/**
* 读取必填配置。
*/
private function requireConfigValue(string $key, string $label): string
{
$value = trim((string) $this->getConfig($key, ''));
if ($value === '') {
throw new PaymentException($label . '未配置', 40200, [
'config_key' => $key,
]);
}
return $value;
}
/**
* 构建上游接口地址。
*/
private function resolveGatewayUrl(string $pathConfigKey, string $defaultPath): string
{
$baseUrl = rtrim($this->requireConfigValue('gateway_url', '上游网关地址'), '/');
$path = trim((string) $this->getConfig($pathConfigKey, $defaultPath));
if ($path === '') {
$path = $defaultPath;
}
if (preg_match('/^https?:\/\//i', $path) === 1) {
return rtrim($path, '/');
}
return $baseUrl . '/' . ltrim($path, '/');
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function requestFormJson(string $method, string $url, array $payload): array
{
$response = $this->request($method, $url, [
'form_params' => $payload,
'headers' => [
'Accept' => 'application/json',
],
]);
return $this->decodeJsonResponse((string) $response->getBody(), $url);
}
/**
* @param array<string, mixed> $query
* @return array<string, mixed>
*/
private function requestQueryJson(string $url, array $query): array
{
$response = $this->request('GET', $url, [
'query' => $query,
'headers' => [
'Accept' => 'application/json',
],
]);
return $this->decodeJsonResponse((string) $response->getBody(), $url);
}
/**
* @return array<string, mixed>
*/
private function decodeJsonResponse(string $body, string $url): array
{
$decoded = json_decode($body, true);
if (!is_array($decoded)) {
throw new PaymentException('上游网关响应不是合法 JSON', 40200, [
'url' => $url,
'body_excerpt' => $this->clipText($body),
]);
}
return $decoded;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function signPayload(array $payload, string $signType, string $key): array
{
$payload['sign_type'] = $signType;
$payload['sign'] = $this->signerManager()->sign($payload, $signType, $key);
return $payload;
}
/**
* @param array<string, mixed> $payload
*/
private function verifyPayloadSignature(array $payload, string $defaultSignType, string $key, string $message): void
{
$sign = trim((string) ($payload['sign'] ?? ''));
if ($sign === '') {
throw new PaymentException($message, 40200, ['reason' => 'missing_sign']);
}
$signType = trim((string) ($payload['sign_type'] ?? $defaultSignType));
if (!$this->signerManager()->verify($payload, $signType, $sign, $key)) {
throw new PaymentException($message, 40200, [
'sign_type' => $signType,
]);
}
}
/**
* @param array<string, string> $defaultMapping
* @return array<string, string>
*/
private function resolveTypeMapping(array $defaultMapping): array
{
$raw = $this->getConfig('type_mapping_json', '');
$mapping = $defaultMapping;
if (is_array($raw)) {
foreach ($raw as $key => $value) {
$source = strtolower(trim((string) $key));
$target = strtolower(trim((string) $value));
if ($source !== '' && $target !== '') {
$mapping[$source] = $target;
}
}
return $mapping;
}
$text = trim((string) $raw);
if ($text === '') {
return $mapping;
}
$decoded = json_decode($text, true);
if (!is_array($decoded)) {
throw new PaymentException('支付方式映射配置不是合法 JSON', 40200, [
'config_key' => 'type_mapping_json',
]);
}
foreach ($decoded as $key => $value) {
$source = strtolower(trim((string) $key));
$target = strtolower(trim((string) $value));
if ($source !== '' && $target !== '') {
$mapping[$source] = $target;
}
}
return $mapping;
}
/**
* @param array<string, string> $defaultMapping
*/
private function resolveUpstreamType(array $order, array $defaultMapping): string
{
$payTypeCode = strtolower(trim((string) ($order['pay_type_code'] ?? '')));
if ($payTypeCode === '') {
throw new PaymentException('订单缺少支付方式编码', 40200);
}
$mapping = $this->resolveTypeMapping($defaultMapping);
$upstreamType = strtolower(trim((string) ($mapping[$payTypeCode] ?? '')));
if ($upstreamType === '') {
throw new PaymentException('未配置上游支付方式映射', 40200, [
'pay_type_code' => $payTypeCode,
]);
}
return $upstreamType;
}
/**
* 获取平台内部支付单号,作为上游商户订单号。
*/
private function resolveOrderNo(array $order): string
{
$orderNo = trim((string) ($order['order_id'] ?? $order['pay_no'] ?? $order['out_trade_no'] ?? ''));
if ($orderNo === '') {
throw new PaymentException('订单缺少订单号', 40200);
}
return $orderNo;
}
/**
* 获取支付金额(分)。
*/
private function resolveAmount(array $order): int
{
$amount = (int) ($order['amount'] ?? $order['pay_amount'] ?? 0);
if ($amount <= 0) {
throw new PaymentException('订单金额不合法', 40200);
}
return $amount;
}
/**
* 获取订单标题。
*/
private function resolveSubject(array $order): string
{
$subject = trim((string) ($order['subject'] ?? $order['body'] ?? ''));
if ($subject === '') {
throw new PaymentException('订单标题不能为空', 40200);
}
if (function_exists('mb_strcut')) {
return mb_strcut($subject, 0, 127, 'UTF-8');
}
return substr($subject, 0, 127);
}
/**
* @return array<string, mixed>
*/
private function resolveExtraContext(array $order): array
{
$context = [];
foreach ([$order['extra'] ?? null, $order['param'] ?? null] as $bag) {
if (is_array($bag)) {
$context = array_merge($context, $bag);
foreach (['merchant', 'payment', 'source'] as $section) {
if (isset($bag[$section]) && is_array($bag[$section])) {
$context = array_merge($context, $bag[$section]);
}
}
continue;
}
if (!is_string($bag)) {
continue;
}
$text = trim($bag);
if ($text === '') {
continue;
}
$decoded = json_decode($text, true);
if (is_array($decoded)) {
$context = array_merge($context, $decoded);
continue;
}
parse_str($text, $parsed);
if (is_array($parsed) && $parsed !== []) {
$context = array_merge($context, $parsed);
}
}
return $context;
}
/**
* 归一化备注透传字段。
*/
private function resolveParamValue(array $order): string
{
$context = $this->resolveExtraContext($order);
$param = $context['param'] ?? null;
if ($param === null || $param === '') {
return '';
}
if (is_scalar($param)) {
return trim((string) $param);
}
$json = json_encode($param, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $json !== false ? $json : '';
}
/**
* 归一化客户端环境。
*/
private function resolveDevice(array $order, string $default = 'pc'): string
{
$device = strtolower(trim((string) ($order['_env'] ?? $order['device'] ?? '')));
return $device !== '' ? $device : $default;
}
/**
* 提取回调入参。
*
* @return array<string, mixed>
*/
private function resolveNotifyPayload(Request $request): array
{
$payload = array_merge((array) $request->get(), (array) $request->post());
if ($payload !== []) {
return $payload;
}
$all = $request->all();
return is_array($all) ? $all : [];
}
/**
* 将分转换为元字符串。
*/
private function amountToMoney(int $amount): string
{
return FormatHelper::amount($amount);
}
/**
* @return array{channel_order_no: string, channel_trade_no: string}
*/
private function resolveChannelNos(array $payload): array
{
$channelOrderNo = trim((string) ($payload['trade_no'] ?? $payload['transaction_id'] ?? ''));
$channelTradeNo = trim((string) ($payload['api_trade_no'] ?? $payload['channel_trade_no'] ?? ''));
if ($channelOrderNo === '' && $channelTradeNo === '') {
throw new PaymentException('上游返回缺少渠道订单号', 40200);
}
if ($channelOrderNo === '') {
$channelOrderNo = $channelTradeNo;
}
if ($channelTradeNo === '') {
$channelTradeNo = $channelOrderNo;
}
return [
'channel_order_no' => $channelOrderNo,
'channel_trade_no' => $channelTradeNo,
];
}
/**
* 归一化回调支付状态。
*/
private function normalizeNotifyStatus(string $tradeStatus): string
{
$tradeStatus = strtoupper(trim($tradeStatus));
if (in_array($tradeStatus, ['TRADE_SUCCESS', 'SUCCESS', 'PAY_SUCCESS', 'FINISHED'], true)) {
return PaymentPluginStatusConstant::SUCCESS;
}
if (in_array($tradeStatus, ['TRADE_FAIL', 'FAILED', 'TRADE_CLOSED', 'CLOSED', 'PAYERROR'], true)) {
return PaymentPluginStatusConstant::FAILED;
}
return PaymentPluginStatusConstant::PENDING;
}
/**
* 生成响应文本摘要。
*/
private function clipText(string $text, int $length = 240): string
{
$text = trim(preg_replace('/\s+/', ' ', $text) ?? $text);
if ($text === '') {
return '';
}
return strlen($text) <= $length ? $text : substr($text, 0, $length) . '...';
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $order
* @return array<string, mixed>
*/
private function buildMockPayResponse(array $payload, array $order): array
{
$seed = strtolower((string) ($payload['out_trade_no'] ?? $this->resolveOrderNo($order)));
$channelOrderNo = 'V1ORD' . strtoupper(substr(md5($seed), 0, 16));
$channelTradeNo = 'V1TRD' . strtoupper(substr(sha1($seed), 0, 16));
return [
'code' => 1,
'msg' => 'success',
'trade_no' => $channelOrderNo,
'api_trade_no' => $channelTradeNo,
'payurl' => $this->resolveMockJumpUrl($channelTradeNo),
];
}
/**
* @param array<string, mixed> $order
* @return array<string, mixed>
*/
private function buildMockQueryResponse(array $order): array
{
$channelOrderNo = trim((string) ($order['chan_order_no'] ?? ''));
$channelTradeNo = trim((string) ($order['chan_trade_no'] ?? ''));
if ($channelOrderNo === '' && $channelTradeNo === '') {
$seed = strtolower($this->resolveOrderNo($order));
$channelOrderNo = 'V1ORD' . strtoupper(substr(md5($seed), 0, 16));
$channelTradeNo = 'V1TRD' . strtoupper(substr(sha1($seed), 0, 16));
} elseif ($channelOrderNo === '') {
$channelOrderNo = $channelTradeNo;
} elseif ($channelTradeNo === '') {
$channelTradeNo = $channelOrderNo;
}
return [
'code' => 1,
'msg' => '查询订单成功',
'trade_no' => $channelOrderNo,
'api_trade_no' => $channelTradeNo,
'out_trade_no' => $this->resolveOrderNo($order),
'status' => 1,
'buyer' => 'MOCK_V1_BUYER',
'param' => $this->resolveParamValue($order),
'endtime' => date('Y-m-d H:i:s'),
];
}
/**
* @param array<string, mixed> $order
* @return array<string, mixed>
*/
private function buildMockRefundResponse(array $order): array
{
$seed = strtolower(trim((string) ($order['refund_no'] ?? $this->resolveOrderNo($order))));
return [
'code' => 1,
'msg' => '退款成功',
'refund_no' => 'V1REF' . strtoupper(substr(md5($seed), 0, 16)),
];
}
/**
* 构建 mock 跳转地址。
*/
private function resolveMockJumpUrl(string $channelTradeNo): string
{
$baseUrl = trim((string) $this->getConfig('mock_jump_base_url', 'https://mock.epay.test/pay/v1'));
if ($baseUrl === '') {
$baseUrl = 'https://mock.epay.test/pay/v1';
}
return rtrim($baseUrl, '/') . '?trade_no=' . rawurlencode($channelTradeNo);
}
/**
* @param array<string, mixed> $response
* @return array<string, mixed>
*/
private function normalizePayResponse(array $response): array
{
$payUrl = trim((string) ($response['payurl'] ?? ''));
if ($payUrl !== '') {
return [
'type' => 'jump',
'payurl' => $payUrl,
'redirect_url' => $payUrl,
];
}
$qrcode = trim((string) ($response['qrcode'] ?? ''));
if ($qrcode !== '') {
return [
'type' => 'qrcode',
'qrcode' => $qrcode,
'qrcode_text' => $qrcode,
];
}
$urlscheme = trim((string) ($response['urlscheme'] ?? ''));
if ($urlscheme !== '') {
return [
'type' => 'urlscheme',
'urlscheme' => $urlscheme,
'redirect_url' => $urlscheme,
];
}
throw new PaymentException('上游 V1 未返回有效支付内容', 40200, [
'response' => $response,
]);
}
}

View File

@@ -0,0 +1,961 @@
<?php
declare(strict_types=1);
namespace app\common\payment;
use app\common\base\BasePayment;
use app\common\constant\AuthConstant;
use app\common\constant\PaymentPluginStatusConstant;
use app\common\interface\PaymentInterface;
use app\common\interface\PayPluginInterface;
use app\common\util\FormatHelper;
use app\exception\PaymentException;
use app\service\payment\epay\EpaySignerManager;
use support\Request;
use support\Response;
/**
* ePay V2 网关插件。
*
* 适用于对接已升级为 V2 协议的第三方平台。
*/
class EpayV2Payment extends BasePayment implements PaymentInterface, PayPluginInterface
{
private ?EpaySignerManager $epaySignerManager = null;
/**
* @var array<string, mixed>
*/
protected array $paymentInfo = [
'code' => 'epay_v2',
'name' => 'ePay V2 网关',
'author' => 'MPAY',
'version' => '1.0.0',
'pay_types' => ['alipay', 'wxpay', 'unionpay'],
'transfer_types' => [],
'config_schema' => [
[
'type' => 'input',
'field' => 'gateway_url',
'title' => '上游网关地址',
'value' => '',
'props' => [
'placeholder' => '例如https://pay.example.com',
],
'validate' => [
['required' => true, 'message' => '上游网关地址不能为空'],
],
],
[
'type' => 'input',
'field' => 'upstream_pid',
'title' => '上游商户ID',
'value' => '',
'props' => [
'placeholder' => '请输入第三方平台分配的 pid',
],
'validate' => [
['required' => true, 'message' => '上游商户ID不能为空'],
],
],
[
'type' => 'textarea',
'field' => 'merchant_private_key',
'title' => '上游商户私钥',
'value' => '',
'props' => [
'placeholder' => '请输入对接上游 V2 的商户 RSA 私钥',
'rows' => 6,
],
'validate' => [
['required' => true, 'message' => '上游商户私钥不能为空'],
],
],
[
'type' => 'textarea',
'field' => 'platform_public_key',
'title' => '上游平台公钥',
'value' => '',
'props' => [
'placeholder' => '请输入上游平台 RSA 公钥',
'rows' => 6,
],
'validate' => [
['required' => true, 'message' => '上游平台公钥不能为空'],
],
],
[
'type' => 'input',
'field' => 'create_path',
'title' => '下单路径',
'value' => '/api/pay/create',
'props' => [
'placeholder' => '默认 /api/pay/create',
],
],
[
'type' => 'input',
'field' => 'query_path',
'title' => '查单路径',
'value' => '/api/pay/query',
'props' => [
'placeholder' => '默认 /api/pay/query',
],
],
[
'type' => 'input',
'field' => 'refund_path',
'title' => '退款路径',
'value' => '/api/pay/refund',
'props' => [
'placeholder' => '默认 /api/pay/refund',
],
],
[
'type' => 'input',
'field' => 'close_path',
'title' => '关单路径',
'value' => '/api/pay/close',
'props' => [
'placeholder' => '默认 /api/pay/close',
],
],
[
'type' => 'textarea',
'field' => 'type_mapping_json',
'title' => '支付方式映射',
'value' => "{\n \"alipay\": \"alipay\",\n \"wxpay\": \"wxpay\",\n \"unionpay\": \"bank\"\n}",
'props' => [
'placeholder' => 'JSON 格式,例如 {\"unionpay\":\"bank\"}',
'rows' => 6,
],
],
],
];
public function init(array $channelConfig): void
{
parent::init($channelConfig);
}
public function pay(array $order): array
{
$payload = [
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
'method' => $this->resolveV2Method($order),
'type' => $this->resolveUpstreamType($order, [
'alipay' => 'alipay',
'wxpay' => 'wxpay',
'unionpay' => 'bank',
]),
'out_trade_no' => $this->resolveOrderNo($order),
'notify_url' => trim((string) ($order['callback_url'] ?? '')),
'name' => $this->resolveSubject($order),
'money' => $this->amountToMoney($this->resolveAmount($order)),
'timestamp' => (string) time(),
'clientip' => trim((string) ($order['client_ip'] ?? '127.0.0.1')),
];
$returnUrl = trim((string) ($order['return_url'] ?? ''));
if ($returnUrl !== '') {
$payload['return_url'] = $returnUrl;
}
$device = $this->resolveDevice($order);
if ($device !== '') {
$payload['device'] = $device;
}
$param = $this->resolveParamValue($order);
if ($param !== '') {
$payload['param'] = $param;
}
$context = $this->resolveExtraContext($order);
foreach (['auth_code', 'sub_openid', 'sub_appid'] as $key) {
$value = trim((string) ($context[$key] ?? ''));
if ($value !== '') {
$payload[$key] = $value;
}
}
$payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $this->requireConfigValue('merchant_private_key', '上游商户私钥'));
$response = $this->isMockEnabled()
? $this->buildMockPayResponse($payload, $order)
: $this->requestFormJson('POST', $this->resolveGatewayUrl('create_path', '/api/pay/create'), $payload);
$this->verifyPayloadSignature(
$response,
AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
$this->requireConfigValue('platform_public_key', '上游平台公钥'),
'上游 V2 下单响应验签失败'
);
if ((int) ($response['code'] ?? -1) !== 0) {
throw new PaymentException((string) ($response['msg'] ?? '上游 V2 下单失败'), 40200, [
'response' => $response,
]);
}
$channelNos = $this->resolveChannelNos($response + [
'trade_no' => (string) ($response['trade_no'] ?? $payload['out_trade_no']),
]);
$payType = strtolower(trim((string) ($response['pay_type'] ?? '')));
$payParams = $this->normalizePayResponse($payType, $response['pay_info'] ?? null);
return [
'pay_product' => (string) $payload['type'],
'pay_action' => (string) ($payParams['type'] ?? $payType),
'pay_params' => $payParams,
'chan_order_no' => $channelNos['channel_order_no'],
'chan_trade_no' => $channelNos['channel_trade_no'],
];
}
public function query(array $order): array
{
$payload = [
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
'timestamp' => (string) time(),
];
$tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? ''));
if ($tradeNo !== '') {
$payload['trade_no'] = $tradeNo;
} else {
$payload['out_trade_no'] = $this->resolveOrderNo($order);
}
$payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $this->requireConfigValue('merchant_private_key', '上游商户私钥'));
$response = $this->isMockEnabled()
? $this->buildMockQueryResponse($order)
: $this->requestFormJson('POST', $this->resolveGatewayUrl('query_path', '/api/pay/query'), $payload);
$this->verifyPayloadSignature(
$response,
AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
$this->requireConfigValue('platform_public_key', '上游平台公钥'),
'上游 V2 查单响应验签失败'
);
if ((int) ($response['code'] ?? -1) !== 0) {
return [
'success' => false,
'msg' => (string) ($response['msg'] ?? '上游 V2 查单失败'),
'raw_data' => $response,
];
}
$channelNos = $this->resolveChannelNos($response);
$statusCode = (int) ($response['status'] ?? 0);
$status = match ($statusCode) {
1, 2 => PaymentPluginStatusConstant::SUCCESS,
default => PaymentPluginStatusConstant::PENDING,
};
return [
'success' => true,
'status' => $status,
'channel_order_no' => $channelNos['channel_order_no'],
'channel_trade_no' => $channelNos['channel_trade_no'],
'channel_status' => (string) $statusCode,
'paid_at' => $response['endtime'] ?? null,
'ext_json' => [
'refundmoney' => (string) ($response['refundmoney'] ?? ''),
'channel_response' => $response,
],
];
}
public function close(array $order): array
{
$payload = [
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
'timestamp' => (string) time(),
];
$tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? ''));
if ($tradeNo !== '') {
$payload['trade_no'] = $tradeNo;
} else {
$payload['out_trade_no'] = $this->resolveOrderNo($order);
}
$payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $this->requireConfigValue('merchant_private_key', '上游商户私钥'));
$response = $this->isMockEnabled()
? $this->buildMockCloseResponse($order)
: $this->requestFormJson('POST', $this->resolveGatewayUrl('close_path', '/api/pay/close'), $payload);
$this->verifyPayloadSignature(
$response,
AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
$this->requireConfigValue('platform_public_key', '上游平台公钥'),
'上游 V2 关单响应验签失败'
);
return [
'success' => (int) ($response['code'] ?? -1) === 0,
'msg' => (string) ($response['msg'] ?? ''),
'raw_data' => $response,
];
}
public function refund(array $order): array
{
$payload = [
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
'money' => $this->amountToMoney((int) ($order['refund_amount'] ?? 0)),
'timestamp' => (string) time(),
];
$tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? ''));
if ($tradeNo !== '') {
$payload['trade_no'] = $tradeNo;
} else {
$payload['out_trade_no'] = $this->resolveOrderNo($order);
}
$outRefundNo = trim((string) ($order['refund_no'] ?? ''));
if ($outRefundNo !== '') {
$payload['out_refund_no'] = $outRefundNo;
}
$payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $this->requireConfigValue('merchant_private_key', '上游商户私钥'));
$response = $this->isMockEnabled()
? $this->buildMockRefundResponse($order)
: $this->requestFormJson('POST', $this->resolveGatewayUrl('refund_path', '/api/pay/refund'), $payload);
$this->verifyPayloadSignature(
$response,
AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
$this->requireConfigValue('platform_public_key', '上游平台公钥'),
'上游 V2 退款响应验签失败'
);
return [
'success' => (int) ($response['code'] ?? -1) === 0,
'msg' => (string) ($response['msg'] ?? ''),
'chan_refund_no' => trim((string) ($response['refund_no'] ?? $response['out_refund_no'] ?? '')),
'raw_data' => $response,
];
}
public function notify(Request $request): array
{
$payload = $this->resolveNotifyPayload($request);
$this->verifyPayloadSignature(
$payload,
AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
$this->requireConfigValue('platform_public_key', '上游平台公钥'),
'上游 V2 回调验签失败'
);
$channelNos = $this->resolveChannelNos($payload);
$status = $this->normalizeNotifyStatus((string) ($payload['trade_status'] ?? ''));
return [
'status' => $status,
'message' => (string) ($payload['trade_status'] ?? ''),
'channel_order_no' => $channelNos['channel_order_no'],
'channel_trade_no' => $channelNos['channel_trade_no'],
'channel_status' => (string) ($payload['trade_status'] ?? ''),
'paid_at' => $payload['endtime'] ?? null,
'ext_json' => [
'channel_type' => (string) ($payload['type'] ?? ''),
'timestamp' => (string) ($payload['timestamp'] ?? ''),
],
];
}
public function notifySuccess(): string|Response
{
return 'success';
}
public function notifyFail(): string|Response
{
return 'fail';
}
/**
* 获取签名管理器。
*/
private function signerManager(): EpaySignerManager
{
if ($this->epaySignerManager === null) {
/** @var EpaySignerManager $manager */
$manager = container_make(EpaySignerManager::class, []);
$this->epaySignerManager = $manager;
}
return $this->epaySignerManager;
}
/**
* 是否启用插件内置 mock。
*/
private function isMockEnabled(): bool
{
$value = $this->getConfig('mock_enabled', false);
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int) $value === 1;
}
$filtered = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
return $filtered ?? false;
}
/**
* 读取必填配置。
*/
private function requireConfigValue(string $key, string $label): string
{
$value = trim((string) $this->getConfig($key, ''));
if ($value === '') {
throw new PaymentException($label . '未配置', 40200, [
'config_key' => $key,
]);
}
return $value;
}
/**
* 构建上游接口地址。
*/
private function resolveGatewayUrl(string $pathConfigKey, string $defaultPath): string
{
$baseUrl = rtrim($this->requireConfigValue('gateway_url', '上游网关地址'), '/');
$path = trim((string) $this->getConfig($pathConfigKey, $defaultPath));
if ($path === '') {
$path = $defaultPath;
}
if (preg_match('/^https?:\/\//i', $path) === 1) {
return rtrim($path, '/');
}
return $baseUrl . '/' . ltrim($path, '/');
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function requestFormJson(string $method, string $url, array $payload): array
{
$response = $this->request($method, $url, [
'form_params' => $payload,
'headers' => [
'Accept' => 'application/json',
],
]);
return $this->decodeJsonResponse((string) $response->getBody(), $url);
}
/**
* @return array<string, mixed>
*/
private function decodeJsonResponse(string $body, string $url): array
{
$decoded = json_decode($body, true);
if (!is_array($decoded)) {
throw new PaymentException('上游网关响应不是合法 JSON', 40200, [
'url' => $url,
'body_excerpt' => $this->clipText($body),
]);
}
return $decoded;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function signPayload(array $payload, string $signType, string $key): array
{
$payload['sign_type'] = $signType;
$payload['sign'] = $this->signerManager()->sign($payload, $signType, $key);
return $payload;
}
/**
* @param array<string, mixed> $payload
*/
private function verifyPayloadSignature(array $payload, string $defaultSignType, string $key, string $message): void
{
$sign = trim((string) ($payload['sign'] ?? ''));
if ($sign === '') {
throw new PaymentException($message, 40200, ['reason' => 'missing_sign']);
}
$signType = trim((string) ($payload['sign_type'] ?? $defaultSignType));
if (!$this->signerManager()->verify($payload, $signType, $sign, $key)) {
throw new PaymentException($message, 40200, [
'sign_type' => $signType,
]);
}
}
/**
* @param array<string, string> $defaultMapping
* @return array<string, string>
*/
private function resolveTypeMapping(array $defaultMapping): array
{
$raw = $this->getConfig('type_mapping_json', '');
$mapping = $defaultMapping;
if (is_array($raw)) {
foreach ($raw as $key => $value) {
$source = strtolower(trim((string) $key));
$target = strtolower(trim((string) $value));
if ($source !== '' && $target !== '') {
$mapping[$source] = $target;
}
}
return $mapping;
}
$text = trim((string) $raw);
if ($text === '') {
return $mapping;
}
$decoded = json_decode($text, true);
if (!is_array($decoded)) {
throw new PaymentException('支付方式映射配置不是合法 JSON', 40200, [
'config_key' => 'type_mapping_json',
]);
}
foreach ($decoded as $key => $value) {
$source = strtolower(trim((string) $key));
$target = strtolower(trim((string) $value));
if ($source !== '' && $target !== '') {
$mapping[$source] = $target;
}
}
return $mapping;
}
/**
* @param array<string, string> $defaultMapping
*/
private function resolveUpstreamType(array $order, array $defaultMapping): string
{
$payTypeCode = strtolower(trim((string) ($order['pay_type_code'] ?? '')));
if ($payTypeCode === '') {
throw new PaymentException('订单缺少支付方式编码', 40200);
}
$mapping = $this->resolveTypeMapping($defaultMapping);
$upstreamType = strtolower(trim((string) ($mapping[$payTypeCode] ?? '')));
if ($upstreamType === '') {
throw new PaymentException('未配置上游支付方式映射', 40200, [
'pay_type_code' => $payTypeCode,
]);
}
return $upstreamType;
}
/**
* 获取平台内部支付单号,作为上游商户订单号。
*/
private function resolveOrderNo(array $order): string
{
$orderNo = trim((string) ($order['order_id'] ?? $order['pay_no'] ?? $order['out_trade_no'] ?? ''));
if ($orderNo === '') {
throw new PaymentException('订单缺少订单号', 40200);
}
return $orderNo;
}
/**
* 获取支付金额(分)。
*/
private function resolveAmount(array $order): int
{
$amount = (int) ($order['amount'] ?? $order['pay_amount'] ?? 0);
if ($amount <= 0) {
throw new PaymentException('订单金额不合法', 40200);
}
return $amount;
}
/**
* 获取订单标题。
*/
private function resolveSubject(array $order): string
{
$subject = trim((string) ($order['subject'] ?? $order['body'] ?? ''));
if ($subject === '') {
throw new PaymentException('订单标题不能为空', 40200);
}
if (function_exists('mb_strcut')) {
return mb_strcut($subject, 0, 127, 'UTF-8');
}
return substr($subject, 0, 127);
}
/**
* @return array<string, mixed>
*/
private function resolveExtraContext(array $order): array
{
$context = [];
foreach ([$order['extra'] ?? null, $order['param'] ?? null] as $bag) {
if (is_array($bag)) {
$context = array_merge($context, $bag);
foreach (['merchant', 'payment', 'source'] as $section) {
if (isset($bag[$section]) && is_array($bag[$section])) {
$context = array_merge($context, $bag[$section]);
}
}
continue;
}
if (!is_string($bag)) {
continue;
}
$text = trim($bag);
if ($text === '') {
continue;
}
$decoded = json_decode($text, true);
if (is_array($decoded)) {
$context = array_merge($context, $decoded);
continue;
}
parse_str($text, $parsed);
if (is_array($parsed) && $parsed !== []) {
$context = array_merge($context, $parsed);
}
}
return $context;
}
/**
* 归一化备注透传字段。
*/
private function resolveParamValue(array $order): string
{
$context = $this->resolveExtraContext($order);
$param = $context['param'] ?? null;
if ($param === null || $param === '') {
return '';
}
if (is_scalar($param)) {
return trim((string) $param);
}
$json = json_encode($param, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $json !== false ? $json : '';
}
/**
* 归一化客户端环境。
*/
private function resolveDevice(array $order, string $default = 'pc'): string
{
$device = strtolower(trim((string) ($order['_env'] ?? $order['device'] ?? '')));
return $device !== '' ? $device : $default;
}
/**
* 解析 V2 上游 method。
*/
private function resolveV2Method(array $order): string
{
$context = $this->resolveExtraContext($order);
$method = strtolower(trim((string) ($context['method'] ?? '')));
$allowed = ['web', 'jump', 'jsapi', 'app', 'scan', 'applet'];
if (in_array($method, $allowed, true)) {
return $method;
}
if (trim((string) ($context['auth_code'] ?? '')) !== '') {
return 'scan';
}
if (trim((string) ($context['sub_openid'] ?? '')) !== '') {
return 'jsapi';
}
return match ($this->resolveDevice($order)) {
'wechat' => 'jsapi',
'mobile', 'qq', 'alipay' => 'jump',
default => 'web',
};
}
/**
* 提取回调入参。
*
* @return array<string, mixed>
*/
private function resolveNotifyPayload(Request $request): array
{
$payload = array_merge((array) $request->get(), (array) $request->post());
if ($payload !== []) {
return $payload;
}
$all = $request->all();
return is_array($all) ? $all : [];
}
/**
* 将分转换为元字符串。
*/
private function amountToMoney(int $amount): string
{
return FormatHelper::amount($amount);
}
/**
* @return array{channel_order_no: string, channel_trade_no: string}
*/
private function resolveChannelNos(array $payload): array
{
$channelOrderNo = trim((string) ($payload['trade_no'] ?? $payload['transaction_id'] ?? ''));
$channelTradeNo = trim((string) ($payload['api_trade_no'] ?? $payload['channel_trade_no'] ?? ''));
if ($channelOrderNo === '' && $channelTradeNo === '') {
throw new PaymentException('上游返回缺少渠道订单号', 40200);
}
if ($channelOrderNo === '') {
$channelOrderNo = $channelTradeNo;
}
if ($channelTradeNo === '') {
$channelTradeNo = $channelOrderNo;
}
return [
'channel_order_no' => $channelOrderNo,
'channel_trade_no' => $channelTradeNo,
];
}
/**
* 归一化回调支付状态。
*/
private function normalizeNotifyStatus(string $tradeStatus): string
{
$tradeStatus = strtoupper(trim($tradeStatus));
if (in_array($tradeStatus, ['TRADE_SUCCESS', 'SUCCESS', 'PAY_SUCCESS', 'FINISHED'], true)) {
return PaymentPluginStatusConstant::SUCCESS;
}
if (in_array($tradeStatus, ['TRADE_FAIL', 'FAILED', 'TRADE_CLOSED', 'CLOSED', 'PAYERROR'], true)) {
return PaymentPluginStatusConstant::FAILED;
}
return PaymentPluginStatusConstant::PENDING;
}
/**
* 生成响应文本摘要。
*/
private function clipText(string $text, int $length = 240): string
{
$text = trim(preg_replace('/\s+/', ' ', $text) ?? $text);
if ($text === '') {
return '';
}
return strlen($text) <= $length ? $text : substr($text, 0, $length) . '...';
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $order
* @return array<string, mixed>
*/
private function buildMockPayResponse(array $payload, array $order): array
{
$seed = strtolower((string) ($payload['out_trade_no'] ?? $this->resolveOrderNo($order)));
$channelOrderNo = 'V2ORD' . strtoupper(substr(md5($seed), 0, 16));
$channelTradeNo = 'V2TRD' . strtoupper(substr(sha1($seed), 0, 16));
return $this->buildMockSignedResponse([
'code' => 0,
'msg' => 'success',
'trade_no' => $channelOrderNo,
'api_trade_no' => $channelTradeNo,
'pay_type' => 'jump',
'pay_info' => [
'type' => 'jump',
'payurl' => $this->resolveMockJumpUrl($channelTradeNo),
],
]);
}
/**
* @param array<string, mixed> $order
* @return array<string, mixed>
*/
private function buildMockQueryResponse(array $order): array
{
$channelOrderNo = trim((string) ($order['chan_order_no'] ?? ''));
$channelTradeNo = trim((string) ($order['chan_trade_no'] ?? ''));
if ($channelOrderNo === '' && $channelTradeNo === '') {
$seed = strtolower($this->resolveOrderNo($order));
$channelOrderNo = 'V2ORD' . strtoupper(substr(md5($seed), 0, 16));
$channelTradeNo = 'V2TRD' . strtoupper(substr(sha1($seed), 0, 16));
} elseif ($channelOrderNo === '') {
$channelOrderNo = $channelTradeNo;
} elseif ($channelTradeNo === '') {
$channelTradeNo = $channelOrderNo;
}
return $this->buildMockSignedResponse([
'code' => 0,
'msg' => 'success',
'trade_no' => $channelOrderNo,
'api_trade_no' => $channelTradeNo,
'out_trade_no' => $this->resolveOrderNo($order),
'status' => 1,
'buyer' => 'MOCK_V2_BUYER',
'param' => $this->resolveParamValue($order),
'refundmoney' => '0.00',
'endtime' => date('Y-m-d H:i:s'),
]);
}
/**
* @param array<string, mixed> $order
* @return array<string, mixed>
*/
private function buildMockCloseResponse(array $order): array
{
return $this->buildMockSignedResponse([
'code' => 0,
'msg' => 'success',
'trade_no' => trim((string) ($order['chan_order_no'] ?? '')),
'api_trade_no' => trim((string) ($order['chan_trade_no'] ?? '')),
]);
}
/**
* @param array<string, mixed> $order
* @return array<string, mixed>
*/
private function buildMockRefundResponse(array $order): array
{
$seed = strtolower(trim((string) ($order['refund_no'] ?? $this->resolveOrderNo($order))));
return $this->buildMockSignedResponse([
'code' => 0,
'msg' => 'success',
'refund_no' => 'V2REF' . strtoupper(substr(md5($seed), 0, 16)),
'out_refund_no' => trim((string) ($order['refund_no'] ?? '')),
'trade_no' => trim((string) ($order['chan_order_no'] ?? '')),
]);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function buildMockSignedResponse(array $payload): array
{
$payload['timestamp'] = (string) ($payload['timestamp'] ?? time());
$payload['sign_type'] = AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA;
$signPayload = $payload;
unset($signPayload['sign'], $signPayload['sign_type']);
$payload['sign'] = $this->signerManager()->sign(
$signPayload,
AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
$this->requireConfigValue('mock_platform_private_key', 'Mock 上游平台私钥')
);
return $payload;
}
/**
* 构建 mock 跳转地址。
*/
private function resolveMockJumpUrl(string $channelTradeNo): string
{
$baseUrl = trim((string) $this->getConfig('mock_jump_base_url', 'https://mock.epay.test/pay/v2'));
if ($baseUrl === '') {
$baseUrl = 'https://mock.epay.test/pay/v2';
}
return rtrim($baseUrl, '/') . '?trade_no=' . rawurlencode($channelTradeNo);
}
/**
* @return array<string, mixed>
*/
private function normalizePayResponse(string $payType, mixed $payInfo): array
{
$payType = strtolower(trim($payType));
$payload = is_array($payInfo) ? $payInfo : [];
if (!is_array($payload)) {
$payload = [];
}
if (!is_array($payInfo)) {
$text = trim((string) $payInfo);
if ($text !== '') {
$payload = match ($payType) {
'jump' => ['payurl' => $text, 'redirect_url' => $text],
'html' => ['html' => $text],
'qrcode' => ['qrcode' => $text, 'qrcode_text' => $text],
'urlscheme' => ['urlscheme' => $text, 'redirect_url' => $text],
default => ['payload' => $text],
};
}
}
$payload['type'] = $payType !== '' ? $payType : (string) ($payload['type'] ?? '');
if ($payload['type'] === 'jump') {
$jumpUrl = trim((string) ($payload['payurl'] ?? $payload['redirect_url'] ?? $payload['url'] ?? ''));
if ($jumpUrl !== '') {
$payload['payurl'] = $jumpUrl;
$payload['redirect_url'] = $jumpUrl;
}
}
if ($payload['type'] === 'qrcode') {
$qrcode = trim((string) ($payload['qrcode'] ?? $payload['qrcode_text'] ?? $payload['qrcode_url'] ?? ''));
if ($qrcode !== '') {
$payload['qrcode'] = $qrcode;
$payload['qrcode_text'] = $qrcode;
}
}
if ($payload['type'] === 'urlscheme') {
$urlscheme = trim((string) ($payload['urlscheme'] ?? $payload['redirect_url'] ?? ''));
if ($urlscheme !== '') {
$payload['urlscheme'] = $urlscheme;
$payload['redirect_url'] = $urlscheme;
}
}
return $payload;
}
}

View File

@@ -0,0 +1,547 @@
<?php
declare(strict_types=1);
namespace app\common\payment;
use app\common\base\BasePayment;
use app\common\constant\AuthConstant;
use app\common\interface\PaymentInterface;
use app\common\interface\PayPluginInterface;
use app\common\util\FormatHelper;
use app\exception\PaymentException;
use support\Request;
use support\Response;
/**
* 支付插件模板示例。
*
* 复制这个类时,通常只需要改下面几处:
* - `paymentInfo` 里的 `code`、`name`、`pay_types`、`config_schema`
* - `init()` 里的 SDK 初始化和配置装配
* - `pay()` 里的真实第三方下单逻辑
* - `query()`、`close()`、`refund()`、`notify()` 里的真实接口调用和验签逻辑
*
* 这是一个安全的起点模板,不依赖任何第三方 SDK。
*/
class TemplatePayment extends BasePayment implements PaymentInterface, PayPluginInterface
{
/**
* 插件元信息。
*
* 复制后请优先修改 `code` 和 `pay_types`,避免和真实插件混淆。
*
* @var array<string, mixed>
*/
protected array $paymentInfo = [
'code' => 'template',
'name' => '模板示例插件',
'author' => 'MPAY',
'version' => '1.0.0',
'pay_types' => ['template'],
'transfer_types' => [],
'config_schema' => [
[
'type' => 'input',
'field' => 'gateway_url',
'title' => '网关地址',
'value' => '',
'props' => [
'placeholder' => '请输入第三方网关地址',
],
'validate' => [
[
'required' => true,
'message' => '网关地址不能为空',
],
],
],
[
'type' => 'input',
'field' => 'merchant_no',
'title' => '商户号',
'value' => '',
'props' => [
'placeholder' => '请输入商户号',
],
'validate' => [
[
'required' => true,
'message' => '商户号不能为空',
],
],
],
[
'type' => 'input',
'field' => 'app_id',
'title' => '应用ID',
'value' => '',
'props' => [
'placeholder' => '请输入应用ID',
],
],
[
'type' => 'textarea',
'field' => 'app_secret',
'title' => '签名密钥/私钥',
'value' => '',
'props' => [
'placeholder' => '请输入签名密钥或私钥内容',
'rows' => 4,
],
'validate' => [
[
'required' => true,
'message' => '签名密钥不能为空',
],
],
],
[
'type' => 'select',
'field' => 'sign_type',
'title' => '签名类型',
'value' => AuthConstant::API_SIGN_NAME_MD5,
'props' => [
'placeholder' => '请选择签名类型',
],
'options' => [
[
'value' => AuthConstant::API_SIGN_NAME_MD5,
'label' => AuthConstant::API_SIGN_NAME_MD5,
],
[
'value' => 'RSA2',
'label' => 'RSA2',
],
],
],
[
'type' => 'select',
'field' => 'default_product',
'title' => '默认支付形态',
'value' => 'html',
'props' => [
'placeholder' => '请选择默认支付形态',
],
'options' => [
[
'value' => 'html',
'label' => '表单跳转',
],
[
'value' => 'qrcode',
'label' => '二维码',
],
[
'value' => 'jump',
'label' => '链接跳转',
],
[
'value' => 'jsapi',
'label' => 'JSAPI / 拉起参数',
],
],
],
],
];
/**
* 初始化插件。
*
* 模板插件这里只做基础注入;真实插件可以在这里初始化 SDK、缓存配置或预处理证书。
*
* @param array $channelConfig 渠道配置
* @return void
*/
public function init(array $channelConfig): void
{
parent::init($channelConfig);
}
/**
* 发起支付下单。
*
* 这里保留的是“模板返回结构”,便于复制后直接替换成真实第三方调用。
*
* @param array $order 订单参数
* @return array{
* pay_product: string,
* pay_action: string,
* pay_params: array<string, mixed>,
* chan_order_no: string,
* chan_trade_no: string
* }
* @throws PaymentException
*/
public function pay(array $order): array
{
$orderNo = $this->requireOrderNo($order);
$amount = $this->requireAmount($order);
$subject = $this->requireSubject($order);
$product = $this->resolveProduct($order);
$payload = $this->buildRequestPayload($order, $orderNo, $amount, $subject);
return [
'pay_product' => $product,
'pay_action' => $product,
'pay_params' => $this->buildPayParams($product, $payload),
'chan_order_no' => $orderNo,
'chan_trade_no' => '',
];
}
/**
* 查询订单状态。
*
* 复制后请在这里替换成真实查单接口。
*
* @param array $order 订单参数
* @return array
* @throws PaymentException
*/
public function query(array $order): array
{
$this->throwTemplateTodo('查单');
return [];
}
/**
* 关闭订单。
*
* 复制后请在这里替换成真实关单接口。
*
* @param array $order 订单参数
* @return array
* @throws PaymentException
*/
public function close(array $order): array
{
$this->throwTemplateTodo('关单');
return [];
}
/**
* 申请退款。
*
* 复制后请在这里替换成真实退款接口。
*
* @param array $order 订单参数
* @return array
* @throws PaymentException
*/
public function refund(array $order): array
{
$this->throwTemplateTodo('退款');
return [];
}
/**
* 解析并验证支付回调通知。
*
* 复制后请在这里替换成真实回调验签和结果解析逻辑。
* 验签失败直接抛出 `PaymentException`,验签通过后返回标准结果数组。
* 如果渠道只返回一个唯一订单号,请同时填充 `channel_order_no` 和 `channel_trade_no`。
*
* @param Request $request 请求对象
* @return array
* @throws PaymentException
*/
public function notify(Request $request): array
{
$this->throwTemplateTodo('回调验签');
return [];
}
/**
* 回调成功响应。
*
* @return string|Response
*/
public function notifySuccess(): string|Response
{
return 'success';
}
/**
* 回调失败响应。
*
* @return string|Response
*/
public function notifyFail(): string|Response
{
return 'fail';
}
/**
* 构造第三方请求参数。
*
* 这里的字段只是示例,复制后按真实第三方接口自行增删。
*
* @param array $order 原始订单参数
* @param string $orderNo 商户订单号
* @param int $amount 金额(分)
* @param string $subject 订单标题
* @return array<string, mixed>
*/
private function buildRequestPayload(array $order, string $orderNo, int $amount, string $subject): array
{
$payload = [
'merchant_no' => (string) $this->getConfig('merchant_no', ''),
'app_id' => (string) $this->getConfig('app_id', ''),
'pay_no' => (string) ($order['pay_no'] ?? $orderNo),
'out_trade_no' => $orderNo,
'biz_no' => (string) ($order['biz_no'] ?? ''),
'trace_no' => (string) ($order['trace_no'] ?? ''),
'channel_request_no' => (string) ($order['channel_request_no'] ?? ''),
'amount' => $amount,
'amount_yuan' => FormatHelper::amount($amount),
'subject' => $subject,
'body' => (string) ($order['body'] ?? ''),
'notify_url' => (string) ($order['callback_url'] ?? ''),
'return_url' => (string) ($order['return_url'] ?? ''),
'device' => (string) ($order['_env'] ?? 'pc'),
'extra' => $this->collectOrderContext($order),
];
$signType = strtoupper((string) $this->getConfig('sign_type', AuthConstant::API_SIGN_NAME_MD5));
$payload['sign_type'] = $signType !== '' ? $signType : AuthConstant::API_SIGN_NAME_MD5;
$payload['sign'] = 'TODO';
return $payload;
}
/**
* 生成支付页返回参数。
*
* @param string $product 支付形态
* @param array<string, mixed> $payload 请求参数
* @return array<string, mixed>
*/
private function buildPayParams(string $product, array $payload): array
{
$gatewayUrl = (string) $this->getConfig('gateway_url', '');
return match ($product) {
'qrcode' => [
'type' => 'qrcode',
'qrcode_text' => '请替换为真实二维码内容',
'qrcode_url' => $gatewayUrl,
'payload' => $payload,
],
'jump' => [
'type' => 'jump',
'redirect_url' => $gatewayUrl,
'payload' => $payload,
],
'jsapi' => [
'type' => 'jsapi',
'order_string' => '请替换为真实调起参数',
'payload' => $payload,
],
default => [
'type' => 'html',
'method' => 'POST',
'action' => $gatewayUrl,
'html' => $this->buildAutoSubmitForm($gatewayUrl, $payload),
'payload' => $payload,
],
};
}
/**
* 生成自动提交表单。
*
* 这是很多表单跳转类插件最常见的返回方式,复制后可以直接改成真实字段。
*
* @param string $action 表单地址
* @param array<string, mixed> $fields 表单字段
* @return string HTML 片段
*/
private function buildAutoSubmitForm(string $action, array $fields): string
{
if ($action === '') {
return '<!-- 请在模板插件中替换为真实表单地址 -->';
}
$inputs = '';
foreach ($fields as $key => $value) {
if (is_array($value)) {
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$value = $encoded !== false ? $encoded : '';
}
$key = htmlspecialchars((string) $key, ENT_QUOTES, 'UTF-8');
$value = htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
$inputs .= sprintf('<input type="hidden" name="%s" value="%s">', $key, $value);
}
$action = htmlspecialchars($action, ENT_QUOTES, 'UTF-8');
return sprintf(
'<form id="template-pay-form" action="%s" method="post">%s</form><script>document.getElementById("template-pay-form").submit();</script>',
$action,
$inputs
);
}
/**
* 归一化订单上下文。
*
* 支付单拉起时,`extra` 使用 merchant/payment/presentation/plugin 分区。
* 模板把常用分区展开到同一层,方便新插件读取 `param`、`method`、`auth_code` 等字段。
*
* @param array $order 原始订单参数
* @return array<string, mixed>
*/
private function collectOrderContext(array $order): array
{
$context = $order;
$extra = $this->normalizeBag($order['extra'] ?? null);
$context = array_merge($context, $extra);
foreach (['merchant', 'payment', 'source'] as $section) {
if (isset($extra[$section]) && is_array($extra[$section])) {
$context = array_merge($context, $extra[$section]);
}
}
$context = array_merge($context, $this->normalizeBag($order['param'] ?? null));
return $context;
}
/**
* 标准化数组、JSON 字符串或查询字符串。
*
* @param mixed $value 原始值
* @return array<string, mixed>
*/
private function normalizeBag(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value)) {
$value = trim($value);
if ($value === '') {
return [];
}
$decoded = json_decode($value, true);
if (is_array($decoded)) {
return $decoded;
}
parse_str($value, $parsed);
if (is_array($parsed) && $parsed !== []) {
return $parsed;
}
}
return [];
}
/**
* 解析默认支付形态。
*
* @param array $order 原始订单参数
* @return string
*/
private function resolveProduct(array $order): string
{
$context = $this->collectOrderContext($order);
$candidates = [
$context['pay_product'] ?? null,
$context['product'] ?? null,
$context['pay_action'] ?? null,
$context['action'] ?? null,
];
foreach ($candidates as $candidate) {
$product = $this->normalizeProductCode((string) $candidate);
if ($product !== '') {
return $product;
}
}
return $this->normalizeProductCode((string) $this->getConfig('default_product', 'html')) ?: 'html';
}
/**
* 规范化支付形态标识。
*
* @param string $product 原始标识
* @return string 标准化后的标识
*/
private function normalizeProductCode(string $product): string
{
$product = strtolower(trim($product));
return in_array($product, ['html', 'qrcode', 'jump', 'jsapi'], true) ? $product : '';
}
/**
* 获取并校验订单号。
*
* @param array $order 原始订单参数
* @return string
* @throws PaymentException
*/
private function requireOrderNo(array $order): string
{
$orderNo = trim((string) ($order['order_id'] ?? $order['pay_no'] ?? $order['out_trade_no'] ?? ''));
if ($orderNo === '') {
throw new PaymentException('模板插件下单缺少订单号', 40200);
}
return $orderNo;
}
/**
* 获取并校验金额。
*
* @param array $order 原始订单参数
* @return int
* @throws PaymentException
*/
private function requireAmount(array $order): int
{
$amount = (int) ($order['amount'] ?? $order['pay_amount'] ?? $order['total_amount'] ?? 0);
if ($amount <= 0) {
throw new PaymentException('模板插件下单金额不合法', 40200);
}
return $amount;
}
/**
* 获取并校验订单标题。
*
* @param array $order 原始订单参数
* @return string
* @throws PaymentException
*/
private function requireSubject(array $order): string
{
$subject = trim((string) ($order['subject'] ?? $order['title'] ?? $order['body'] ?? ''));
if ($subject === '') {
throw new PaymentException('模板插件下单缺少标题', 40200);
}
return $subject;
}
/**
* 抛出模板占位异常。
*
* @param string $action 当前动作
* @return void
* @throws PaymentException
*/
private function throwTemplateTodo(string $action): void
{
throw new PaymentException(sprintf('模板插件示例未实现%s逻辑请复制后接入真实网关', $action), 40200);
}
}

View File

@@ -6,6 +6,8 @@ use Firebase\JWT\ExpiredException;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\SignatureInvalidException;
use app\common\constant\AuthConstant;
use app\exception\AuthConfigException;
use support\Redis;
use Throwable;
@@ -29,6 +31,7 @@ class JwtTokenManager
* @param array<string, mixed> $sessionData 会话数据
* @param int|null $ttlSeconds 过期秒数
* @return array{token:string,expires_in:int,jti:string,claims:array<string, mixed>,session:array<string, mixed>} 签发结果
* @throws AuthConfigException
*/
public function issue(string $guard, array $claims, array $sessionData, ?int $ttlSeconds = null): array
{
@@ -47,7 +50,7 @@ class JwtTokenManager
'guard' => $guard,
], $claims);
$token = JWT::encode($payload, (string) $guardConfig['secret'], 'HS256');
$token = JWT::encode($payload, (string) $guardConfig['secret'], AuthConstant::JWT_ALGORITHM_HS256);
$session = array_merge($sessionData, [
'guard' => $guard,
@@ -80,6 +83,7 @@ class JwtTokenManager
* @param string $ip 最近访问 IP
* @param string $userAgent 用户Agent
* @return array{claims:array<string, mixed>,session:array<string, mixed>}|null 验证结果
* @throws AuthConfigException
*/
public function verify(string $guard, string $token, string $ip = '', string $userAgent = ''): ?array
{
@@ -126,6 +130,7 @@ class JwtTokenManager
* @param string $guard 登录域
* @param string $token JWT 字符串
* @return bool 是否已撤销
* @throws AuthConfigException
*/
public function revoke(string $guard, string $token): bool
{
@@ -150,6 +155,7 @@ class JwtTokenManager
* @param string $guard 登录域
* @param string $jti 会话标识
* @return bool 是否已撤销
* @throws AuthConfigException
*/
public function revokeByJti(string $guard, string $jti): bool
{
@@ -168,6 +174,7 @@ class JwtTokenManager
* @param string $guard 登录域
* @param string $jti 会话标识
* @return array<string, mixed>|null 会话数据
* @throws AuthConfigException
*/
public function session(string $guard, string $jti): ?array
{
@@ -188,6 +195,7 @@ class JwtTokenManager
* @param string $guard 登录域
* @param string $token JWT 字符串
* @return array<string, mixed>|null JWT 载荷
* @throws AuthConfigException
*/
protected function decode(string $guard, string $token): ?array
{
@@ -196,7 +204,7 @@ class JwtTokenManager
try {
JWT::$leeway = (int) config('auth.leeway', 30);
$payload = JWT::decode($token, new Key((string) $guardConfig['secret'], 'HS256'));
$payload = JWT::decode($token, new Key((string) $guardConfig['secret'], AuthConstant::JWT_ALGORITHM_HS256));
} catch (ExpiredException|SignatureInvalidException|Throwable) {
return null;
}
@@ -221,6 +229,7 @@ class JwtTokenManager
* @param array<string, mixed> $session 会话数据
* @param int $ttlSeconds 过期秒数
* @return void
* @throws AuthConfigException
*/
protected function storeSession(string $guard, string $jti, array $session, int $ttlSeconds): void
{
@@ -239,6 +248,7 @@ class JwtTokenManager
* @param string $guard 登录域
* @param string $jti 会话标识
* @return string Redis 会话键
* @throws AuthConfigException
*/
protected function sessionKey(string $guard, string $jti): string
{
@@ -250,13 +260,15 @@ class JwtTokenManager
*
* @param string $guard 登录域
* @return array<string, mixed> 认证配置
* @throws \InvalidArgumentException
* @throws AuthConfigException
*/
protected function guardConfig(string $guard): array
{
$guards = (array) config('auth.guards', []);
if (!isset($guards[$guard])) {
throw new \InvalidArgumentException("Unknown auth guard: {$guard}");
throw new AuthConfigException("未知认证域:{$guard}", [
'guard' => $guard,
]);
}
return $guards[$guard];
@@ -268,7 +280,7 @@ class JwtTokenManager
* @param string $guard 登录域
* @param string $secret 密钥
* @return void
* @throws RuntimeException
* @throws AuthConfigException
*/
protected function assertHmacSecretLength(string $guard, string $secret): void
{
@@ -277,17 +289,19 @@ class JwtTokenManager
}
$envNames = match ($guard) {
'admin' => 'AUTH_ADMIN_JWT_SECRET or AUTH_JWT_SECRET',
'merchant' => 'AUTH_MERCHANT_JWT_SECRET or AUTH_JWT_SECRET',
default => 'the configured JWT secret',
AuthConstant::GUARD_ADMIN => 'AUTH_ADMIN_JWT_SECRET AUTH_JWT_SECRET',
AuthConstant::GUARD_MERCHANT => 'AUTH_MERCHANT_JWT_SECRET AUTH_JWT_SECRET',
default => '当前配置的 JWT 密钥',
};
throw new \RuntimeException(sprintf(
'JWT secret for guard "%s" is too short for HS256. Please set %s to at least 32 ASCII characters.',
throw new AuthConfigException(sprintf(
'认证域 "%s" 的 JWT 密钥长度不足HS256 至少需要 32 ASCII 字符,请将 %s 配置为至少 32 个字符。',
$guard,
$envNames
));
), [
'guard' => $guard,
'env_names' => $envNames,
'secret_length' => strlen($secret),
]);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace app\common\util;
use RuntimeException;
/**
* RSA 密钥对生成器。
*
* 统一用于后台自动生成商户 RSA 公私钥对,避免各处重复实现。
*/
final class RsaKeyPairGenerator
{
/**
* 生成 RSA 密钥对。
*
* @param int $bits 密钥长度
* @return array{private_key: string, public_key: string}
*/
public static function generate(int $bits = 2048): array
{
while (openssl_error_string()) {
}
$configPath = self::resolveOpenSslConfigPath();
if ($configPath === '') {
throw new RuntimeException('生成 RSA 密钥对失败,未找到可用的 openssl.cnf 配置文件');
}
$resource = openssl_pkey_new([
'private_key_bits' => max(1024, $bits),
'private_key_type' => OPENSSL_KEYTYPE_RSA,
'config' => $configPath,
]);
if ($resource === false) {
throw new RuntimeException('生成 RSA 密钥对失败:' . self::collectOpenSslErrors());
}
$privateKey = '';
if (!openssl_pkey_export($resource, $privateKey, null, ['config' => $configPath]) || trim($privateKey) === '') {
throw new RuntimeException('导出 RSA 私钥失败:' . self::collectOpenSslErrors());
}
$details = openssl_pkey_get_details($resource);
$publicKey = trim((string) ($details['key'] ?? ''));
if ($publicKey === '') {
throw new RuntimeException('导出 RSA 公钥失败');
}
return [
'private_key' => trim($privateKey),
'public_key' => $publicKey,
];
}
/**
* 查找可用的 OpenSSL 配置文件。
*
* @return string 配置文件路径
*/
private static function resolveOpenSslConfigPath(): string
{
$candidates = [];
$envConfig = trim((string) getenv('OPENSSL_CONF'));
if ($envConfig !== '') {
$candidates[] = $envConfig;
}
$baseDir = dirname(PHP_BINARY);
$candidates[] = $baseDir . DIRECTORY_SEPARATOR . 'extras' . DIRECTORY_SEPARATOR . 'ssl' . DIRECTORY_SEPARATOR . 'openssl.cnf';
$candidates[] = $baseDir . DIRECTORY_SEPARATOR . 'openssl.cnf';
$candidates[] = dirname($baseDir) . DIRECTORY_SEPARATOR . 'Apache2.4.39' . DIRECTORY_SEPARATOR . 'conf' . DIRECTORY_SEPARATOR . 'openssl.cnf';
$candidates[] = 'C:\\Program Files\\Git\\mingw64\\etc\\ssl\\openssl.cnf';
$candidates[] = 'C:\\Program Files\\Git\\usr\\ssl\\openssl.cnf';
foreach ($candidates as $candidate) {
$candidate = trim((string) $candidate);
if ($candidate !== '' && is_file($candidate)) {
return $candidate;
}
}
return '';
}
/**
* 收集当前 OpenSSL 错误栈。
*
* @return string 错误信息
*/
private static function collectOpenSslErrors(): string
{
$messages = [];
while ($message = openssl_error_string()) {
$messages[] = $message;
}
return $messages ? implode(' | ', $messages) : 'unknown error';
}
}