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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user