mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-05-09 18:34:26 +08:00
1. 维护代码健壮
2. 更新项目结构文档
This commit is contained in:
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => '商户后台',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
67
app/common/constant/EventConstant.php
Normal file
67
app/common/constant/EventConstant.php
Normal 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';
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -68,4 +68,3 @@ final class MerchantConstant
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 => '清算完成',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
98
app/common/constant/PaymentPluginStatusConstant.php
Normal file
98
app/common/constant/PaymentPluginStatusConstant.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
36
app/common/constant/TransferConstant.php
Normal file
36
app/common/constant/TransferConstant.php
Normal 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 => '失败',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
798
app/common/payment/EpayV1Payment.php
Normal file
798
app/common/payment/EpayV1Payment.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
961
app/common/payment/EpayV2Payment.php
Normal file
961
app/common/payment/EpayV2Payment.php
Normal 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;
|
||||
}
|
||||
}
|
||||
547
app/common/payment/TemplatePayment.php
Normal file
547
app/common/payment/TemplatePayment.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
101
app/common/util/RsaKeyPairGenerator.php
Normal file
101
app/common/util/RsaKeyPairGenerator.php
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user