1. 维护代码健壮

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

View File

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

View File

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

View File

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

View File

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