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:
72
app/service/payment/epay/EpaySignerAbstract.php
Normal file
72
app/service/payment/epay/EpaySignerAbstract.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\payment\epay;
|
||||
|
||||
/**
|
||||
* ePay 签名器抽象基类。
|
||||
*
|
||||
* 负责公共签名原文拼装与 PEM 密钥归一化。
|
||||
*/
|
||||
abstract class EpaySignerAbstract
|
||||
{
|
||||
/**
|
||||
* 构造待签名原文。
|
||||
*
|
||||
* @param array<string, mixed> $params 待签名参数
|
||||
* @return string 签名原文
|
||||
*/
|
||||
protected function buildContent(array $params): string
|
||||
{
|
||||
ksort($params);
|
||||
$parts = [];
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
if ($key === 'sign' || $key === 'sign_type') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = $key . '=' . (string) $value;
|
||||
}
|
||||
|
||||
return implode('&', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化 PEM 密钥。
|
||||
*
|
||||
* @param string $key 原始密钥
|
||||
* @param string $type 密钥类型
|
||||
* @return string PEM 格式密钥
|
||||
*/
|
||||
protected function normalizePem(string $key, string $type): string
|
||||
{
|
||||
$key = trim($key);
|
||||
if ($key === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (str_contains($key, 'BEGIN ')) {
|
||||
return $key;
|
||||
}
|
||||
|
||||
$type = strtoupper(trim($type));
|
||||
$body = preg_replace('/\s+/', '', $key) ?? $key;
|
||||
|
||||
return sprintf(
|
||||
"-----BEGIN %s KEY-----\n%s\n-----END %s KEY-----",
|
||||
$type,
|
||||
trim(chunk_split($body, 64, "\n")),
|
||||
$type
|
||||
);
|
||||
}
|
||||
}
|
||||
30
app/service/payment/epay/EpaySignerInterface.php
Normal file
30
app/service/payment/epay/EpaySignerInterface.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\payment\epay;
|
||||
|
||||
/**
|
||||
* ePay 签名器契约。
|
||||
*/
|
||||
interface EpaySignerInterface
|
||||
{
|
||||
/**
|
||||
* 生成签名。
|
||||
*
|
||||
* @param array<string, mixed> $params 待签名参数
|
||||
* @param string $key 密钥
|
||||
* @return string 签名结果
|
||||
*/
|
||||
public function sign(array $params, string $key): string;
|
||||
|
||||
/**
|
||||
* 验证签名。
|
||||
*
|
||||
* @param array<string, mixed> $params 待验签参数
|
||||
* @param string $sign 签名值
|
||||
* @param string $key 密钥
|
||||
* @return bool 是否通过
|
||||
*/
|
||||
public function verify(array $params, string $sign, string $key): bool;
|
||||
}
|
||||
75
app/service/payment/epay/EpaySignerManager.php
Normal file
75
app/service/payment/epay/EpaySignerManager.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\payment\epay;
|
||||
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\exception\PaymentException;
|
||||
|
||||
/**
|
||||
* ePay 签名器管理器。
|
||||
*
|
||||
* 负责根据签名类型分发 MD5 与 RSA 实现。
|
||||
*/
|
||||
class EpaySignerManager
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Md5Signer $md5Signer,
|
||||
private readonly RsaSigner $rsaSigner
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名。
|
||||
*
|
||||
* @param array<string, mixed> $params 待签名参数
|
||||
* @param string $signType 签名类型
|
||||
* @param string $key 密钥
|
||||
* @return string 签名结果
|
||||
*/
|
||||
public function sign(array $params, string $signType, string $key): string
|
||||
{
|
||||
return match ($this->normalizeSignType($signType)) {
|
||||
AuthConstant::API_SIGN_NAME_MD5 => $this->md5Signer->sign($params, $key),
|
||||
AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA => $this->rsaSigner->sign($params, $key),
|
||||
default => throw new PaymentException('不支持的签名类型', 40200),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证签名。
|
||||
*
|
||||
* @param array<string, mixed> $params 待验签参数
|
||||
* @param string $signType 签名类型
|
||||
* @param string $sign 签名值
|
||||
* @param string $key 密钥
|
||||
* @return bool 是否通过
|
||||
*/
|
||||
public function verify(array $params, string $signType, string $sign, string $key): bool
|
||||
{
|
||||
return match ($this->normalizeSignType($signType)) {
|
||||
AuthConstant::API_SIGN_NAME_MD5 => $this->md5Signer->verify($params, $sign, $key),
|
||||
AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA => $this->rsaSigner->verify($params, $sign, $key),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化签名类型。
|
||||
*
|
||||
* @param string $signType 原始签名类型
|
||||
* @return string 归一化后的签名类型
|
||||
*/
|
||||
public function normalizeSignType(string $signType): string
|
||||
{
|
||||
$signType = strtoupper(trim($signType));
|
||||
|
||||
return match ($signType) {
|
||||
'RSA' => AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA,
|
||||
AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA => AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA,
|
||||
AuthConstant::API_SIGN_NAME_MD5 => AuthConstant::API_SIGN_NAME_MD5,
|
||||
default => $signType,
|
||||
};
|
||||
}
|
||||
}
|
||||
864
app/service/payment/epay/EpayV1ProtocolService.php
Normal file
864
app/service/payment/epay/EpayV1ProtocolService.php
Normal file
@@ -0,0 +1,864 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\epay;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\payment\BizOrder;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\model\payment\PaymentType;
|
||||
use app\repository\account\balance\MerchantAccountRepository;
|
||||
use app\repository\payment\settlement\SettlementOrderRepository;
|
||||
use app\repository\payment\trade\BizOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\service\merchant\MerchantService;
|
||||
use app\service\merchant\security\MerchantApiCredentialService;
|
||||
use app\service\payment\config\PaymentTypeService;
|
||||
use app\service\payment\order\PayOrderService;
|
||||
use app\service\payment\order\PaymentOrderInputAssembler;
|
||||
use app\service\payment\order\RefundService;
|
||||
use app\service\payment\runtime\PaymentPluginManager;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* ePay V1 协议服务。
|
||||
*
|
||||
* 负责将旧协议请求转换为当前支付、退款和查询流程。
|
||||
*
|
||||
* @property MerchantApiCredentialService $merchantApiCredentialService 商户 API 凭证服务
|
||||
* @property PaymentTypeService $paymentTypeService 支付类型服务
|
||||
* @property PayOrderService $payOrderService 支付订单服务
|
||||
* @property PaymentOrderInputAssembler $orderInputAssembler 支付入参组装器
|
||||
* @property PaymentPluginManager $paymentPluginManager 支付插件管理器
|
||||
* @property MerchantAccountRepository $merchantAccountRepository 商户账户仓库
|
||||
* @property BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @property PayOrderRepository $payOrderRepository 支付单仓库
|
||||
* @property SettlementOrderRepository $settlementOrderRepository 结算订单仓库
|
||||
* @property RefundService $refundService 退款服务
|
||||
*/
|
||||
class EpayV1ProtocolService extends BaseService
|
||||
{
|
||||
private const API_ACTIONS = ['query', 'settle', 'order', 'orders', 'refund'];
|
||||
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param MerchantApiCredentialService $merchantApiCredentialService 商户 API 凭证服务
|
||||
* @param PaymentTypeService $paymentTypeService 支付类型服务
|
||||
* @param PayOrderService $payOrderService 支付订单服务
|
||||
* @param PaymentPluginManager $paymentPluginManager 支付插件管理器
|
||||
* @param MerchantAccountRepository $merchantAccountRepository 商户账户仓库
|
||||
* @param BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @param PayOrderRepository $payOrderRepository 支付订单仓库
|
||||
* @param SettlementOrderRepository $settlementOrderRepository 结算订单仓库
|
||||
* @param RefundService $refundService 退款服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantApiCredentialService $merchantApiCredentialService,
|
||||
protected MerchantService $merchantService,
|
||||
protected PaymentTypeService $paymentTypeService,
|
||||
protected PayOrderService $payOrderService,
|
||||
protected PaymentOrderInputAssembler $orderInputAssembler,
|
||||
protected PaymentPluginManager $paymentPluginManager,
|
||||
protected EpaySignerManager $epaySignerManager,
|
||||
protected MerchantAccountRepository $merchantAccountRepository,
|
||||
protected BizOrderRepository $bizOrderRepository,
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected SettlementOrderRepository $settlementOrderRepository,
|
||||
protected RefundService $refundService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理页面跳转支付入口。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 跳转响应或错误 JSON
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function submit(array $payload, Request $request): Response
|
||||
{
|
||||
try {
|
||||
$typeCode = trim((string) ($payload['type'] ?? ''));
|
||||
if ($typeCode === '') {
|
||||
// `type` 为空时先创建收银台业务单,选完方式后再进入正式支付单流程。
|
||||
$attempt = $this->prepareCashierSubmit($payload, $request);
|
||||
$targetUrl = (string) ($attempt['cashier_url'] ?? '');
|
||||
if ($targetUrl === '') {
|
||||
throw new ValidationException('收银台跳转地址生成失败');
|
||||
}
|
||||
|
||||
return redirect($targetUrl);
|
||||
}
|
||||
|
||||
return $this->buildBrowserSubmitResponse($this->prepareSubmitAttempt($payload, $request));
|
||||
} catch (Throwable $e) {
|
||||
return json([
|
||||
'code' => 0,
|
||||
'msg' => $this->normalizeErrorMessage($e),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 API 支付入口。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @param Request $request 请求对象
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function mapi(array $payload, Request $request): array
|
||||
{
|
||||
try {
|
||||
$attempt = $this->prepareSubmitAttempt($payload, $request);
|
||||
return $this->buildMapiResponse($attempt);
|
||||
} catch (Throwable $e) {
|
||||
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理旧版兼容入口。
|
||||
*
|
||||
* 支持 `query`、`settle`、`order`、`orders` 和 `refund` 五种操作。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function api(array $payload): array
|
||||
{
|
||||
$act = strtolower(trim((string) ($payload['act'] ?? '')));
|
||||
if (!in_array($act, self::API_ACTIONS, true)) {
|
||||
return ['code' => 0, 'msg' => '不支持的操作类型'];
|
||||
}
|
||||
|
||||
return match ($act) {
|
||||
'query' => $this->queryMerchantInfo($payload),
|
||||
'settle' => $this->querySettlementList($payload),
|
||||
'order' => $this->queryOrder($payload),
|
||||
'orders' => $this->queryOrders($payload),
|
||||
'refund' => $this->createRefund($payload),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户信息,对应 `act=query`。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function queryMerchantInfo(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchantId = (int) ($payload['pid'] ?? 0);
|
||||
$key = trim((string) ($payload['key'] ?? ''));
|
||||
$auth = $this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
|
||||
$merchant = $auth['merchant'];
|
||||
$credential = $auth['credential'];
|
||||
$account = $this->merchantAccountRepository->findByMerchantId($merchantId);
|
||||
// 旧协议会同时返回总单量、今日单量和昨日单量,便于上游直接做商户概览。
|
||||
$todayDate = FormatHelper::timestamp(time(), 'Y-m-d');
|
||||
$lastDayDate = FormatHelper::timestamp(strtotime('-1 day'), 'Y-m-d');
|
||||
$totalOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->count();
|
||||
$todayOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->whereDate('created_at', $todayDate)->count();
|
||||
$lastDayOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->whereDate('created_at', $lastDayDate)->count();
|
||||
|
||||
return [
|
||||
'code' => 1,
|
||||
'pid' => (int) $merchant->id,
|
||||
'key' => (string) $credential->api_key,
|
||||
'active' => (int) $merchant->status,
|
||||
'money' => FormatHelper::amount((int) ($account->available_balance ?? 0)),
|
||||
'type' => (int) ($merchant->settle_type ?? 4),
|
||||
'account' => (string) $merchant->settlement_account_no,
|
||||
'username' => (string) $merchant->settlement_account_name,
|
||||
'orders' => $totalOrders,
|
||||
'order_today' => $todayOrders,
|
||||
'order_lastday' => $lastDayOrders,
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询结算记录列表,对应 `act=settle`。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function querySettlementList(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchantId = (int) ($payload['pid'] ?? 0);
|
||||
$key = trim((string) ($payload['key'] ?? ''));
|
||||
$this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
|
||||
$rows = $this->settlementOrderRepository->query()->where('merchant_id', $merchantId)->orderByDesc('id')->get();
|
||||
|
||||
// 旧协议列表只需要基础字段和金额文本,这里直接整理成可展示数组。
|
||||
return [
|
||||
'code' => 1,
|
||||
'msg' => '查询结算记录成功!',
|
||||
'data' => $rows->map(function ($row): array {
|
||||
return [
|
||||
'settle_no' => (string) $row->settle_no,
|
||||
'cycle_type' => (int) $row->cycle_type,
|
||||
'cycle_key' => (string) $row->cycle_key,
|
||||
'status' => (int) $row->status,
|
||||
'gross_amount' => FormatHelper::amount((int) $row->gross_amount),
|
||||
'net_amount' => FormatHelper::amount((int) $row->net_amount),
|
||||
'accounted_amount' => FormatHelper::amount((int) $row->accounted_amount),
|
||||
'created_at' => FormatHelper::dateTime($row->created_at ?? null),
|
||||
'completed_at' => FormatHelper::dateTime($row->completed_at ?? null),
|
||||
];
|
||||
})->all(),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单个订单,对应 `act=order`。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function queryOrder(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchantId = (int) ($payload['pid'] ?? 0);
|
||||
$key = trim((string) ($payload['key'] ?? ''));
|
||||
$this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
|
||||
$context = $this->resolvePayOrderContext($merchantId, $payload);
|
||||
if (!$context) {
|
||||
return ['code' => 0, 'msg' => '订单不存在'];
|
||||
}
|
||||
|
||||
// 旧协议查询单号时,要把支付单和业务单合并成同一份响应结构。
|
||||
return ['code' => 1, 'msg' => '查询订单号成功!'] + $this->formatEpayOrderRow($context['pay_order'], $context['biz_order']);
|
||||
} catch (Throwable $e) {
|
||||
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询订单,对应 `act=orders`。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function queryOrders(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchantId = (int) ($payload['pid'] ?? 0);
|
||||
$key = trim((string) ($payload['key'] ?? ''));
|
||||
$this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
|
||||
// 旧接口默认只允许一次拉少量订单,这里沿用上限 50 的兼容口径。
|
||||
$limit = min(50, max(1, (int) ($payload['limit'] ?? 20)));
|
||||
$page = max(1, (int) ($payload['page'] ?? 1));
|
||||
$paginator = $this->payOrderRepository->query()->where('merchant_id', $merchantId)->orderByDesc('id')->paginate($limit, ['*'], 'page', $page);
|
||||
$items = $paginator->items();
|
||||
$bizOrderMap = [];
|
||||
$bizNos = array_values(array_unique(array_filter(array_map(function ($row): string {
|
||||
return trim((string) ($row->biz_no ?? ''));
|
||||
}, $items))));
|
||||
|
||||
if ($bizNos !== []) {
|
||||
foreach ($this->bizOrderRepository->query()->whereIn('biz_no', $bizNos)->get() as $bizOrder) {
|
||||
$bizOrderMap[(string) $bizOrder->biz_no] = $bizOrder;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 1,
|
||||
'msg' => '查询结算记录成功!',
|
||||
// 批量查询和单条查询共用同一套格式化器,避免字段口径不一致。
|
||||
'data' => array_map(function ($row) use ($bizOrderMap): array {
|
||||
$bizNo = (string) ($row->biz_no ?? '');
|
||||
|
||||
return $this->formatEpayOrderRow($row, $bizOrderMap[$bizNo] ?? null);
|
||||
}, $items),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交退款申请,对应 `act=refund`。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function createRefund(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchantId = (int) ($payload['pid'] ?? 0);
|
||||
$key = trim((string) ($payload['key'] ?? ''));
|
||||
$this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
|
||||
// 先确认退款目标单据归属当前商户,避免旧协议拿着别人的单号误发退款。
|
||||
$context = $this->resolvePayOrderContext($merchantId, $payload);
|
||||
if (!$context) {
|
||||
return ['code' => 0, 'msg' => '订单不存在'];
|
||||
}
|
||||
|
||||
/** @var PayOrder $payOrder */
|
||||
$payOrder = $context['pay_order'];
|
||||
$refundAmount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0'));
|
||||
if ($refundAmount <= 0) {
|
||||
return ['code' => 0, 'msg' => '退款金额不合法'];
|
||||
}
|
||||
|
||||
$refundOrder = $this->refundService->createRefund([
|
||||
'pay_no' => (string) $payOrder->pay_no,
|
||||
'merchant_refund_no' => trim((string) ($payload['refund_no'] ?? $payload['merchant_refund_no'] ?? '')),
|
||||
'refund_amount' => $refundAmount,
|
||||
'reason' => trim((string) ($payload['reason'] ?? '')),
|
||||
]);
|
||||
|
||||
$plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true);
|
||||
// 不同插件返回的退款结果字段不完全一致,这里仍按旧协议的退款参数重新组织一次。
|
||||
$pluginResult = $plugin->refund([
|
||||
'order_id' => (string) $payOrder->pay_no,
|
||||
'pay_no' => (string) $payOrder->pay_no,
|
||||
'biz_no' => (string) $payOrder->biz_no,
|
||||
'chan_order_no' => (string) $payOrder->channel_order_no,
|
||||
'chan_trade_no' => (string) $payOrder->channel_trade_no,
|
||||
'out_trade_no' => (string) $payOrder->channel_order_no,
|
||||
'refund_no' => (string) $refundOrder->refund_no,
|
||||
'refund_amount' => $refundAmount,
|
||||
'refund_reason' => trim((string) ($payload['reason'] ?? '')),
|
||||
'extra' => (array) ($payOrder->ext_json ?? []),
|
||||
]);
|
||||
|
||||
if (!$this->isPluginSuccess($pluginResult)) {
|
||||
// 渠道明确失败时,先把退款单推进失败态,再把旧协议响应收口成失败文案。
|
||||
$this->refundService->markRefundFailed((string) $refundOrder->refund_no, [
|
||||
'failed_at' => $this->now(),
|
||||
'last_error' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败'),
|
||||
'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult),
|
||||
]);
|
||||
|
||||
return ['code' => 0, 'msg' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败')];
|
||||
}
|
||||
|
||||
$this->refundService->markRefundSuccess((string) $refundOrder->refund_no, [
|
||||
'succeeded_at' => $this->now(),
|
||||
'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult),
|
||||
]);
|
||||
|
||||
return ['code' => 1, 'msg' => '退款成功'];
|
||||
} catch (Throwable $e) {
|
||||
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预处理支付提交请求。
|
||||
*
|
||||
* 这里负责把旧协议载荷转换为当前支付单创建所需的数据结构。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @param Request $request 请求对象
|
||||
* @return array<string, mixed> 预处理数据
|
||||
*/
|
||||
private function prepareSubmitAttempt(array $payload, Request $request): array
|
||||
{
|
||||
// 先把旧协议载荷转换成当前系统的统一入参,再交给支付单主流程处理。
|
||||
$normalized = $this->normalizeSubmitPayload($payload, $request, false);
|
||||
$result = $this->payOrderService->preparePayAttempt($normalized);
|
||||
$payOrder = $result['pay_order'];
|
||||
$payParams = (array) ($result['pay_params'] ?? []);
|
||||
|
||||
return [
|
||||
'normalized_payload' => $normalized,
|
||||
'result' => $result,
|
||||
'pay_order' => $payOrder,
|
||||
'pay_params' => $payParams,
|
||||
'payment_page_url' => $this->buildPaymentPageUrl((string) $payOrder->pay_no),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 预创建收银台业务单。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @param Request $request 请求对象
|
||||
* @return array<string, mixed> 预处理数据
|
||||
*/
|
||||
private function prepareCashierSubmit(array $payload, Request $request): array
|
||||
{
|
||||
$normalized = $this->normalizeSubmitPayload($payload, $request, true);
|
||||
$result = $this->payOrderService->prepareCashierBizOrder($normalized);
|
||||
|
||||
return [
|
||||
'normalized_payload' => $normalized,
|
||||
'result' => $result,
|
||||
'merchant' => $result['merchant'] ?? null,
|
||||
'biz_order' => $result['biz_order'] ?? null,
|
||||
'cashier_url' => (string) ($result['cashier_url'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化提交支付参数。
|
||||
*
|
||||
* 这里会完成签名校验、金额转分、支付方式解析,并把旧协议字段写入扩展信息。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @param Request $request 请求对象
|
||||
* @return array<string, mixed> 当前支付单创建参数
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function normalizeSubmitPayload(array $payload, Request $request, bool $allowEmptyType = false): array
|
||||
{
|
||||
$merchantId = (int) ($payload['pid'] ?? 0);
|
||||
if ($merchantId <= 0) {
|
||||
throw new ValidationException('pid 参数不能为空');
|
||||
}
|
||||
|
||||
$sign = trim((string) ($payload['sign'] ?? ''));
|
||||
if ($sign === '') {
|
||||
throw new ValidationException('sign 参数不能为空');
|
||||
}
|
||||
|
||||
$credential = $this->merchantApiCredentialService->findByMerchantId($merchantId);
|
||||
if (!$credential || (int) $credential->status !== AuthConstant::CREDENTIAL_STATUS_ENABLED) {
|
||||
throw new ValidationException('商户 API 凭证未开通');
|
||||
}
|
||||
|
||||
$signType = strtoupper((string) ($payload['sign_type'] ?? AuthConstant::API_SIGN_NAME_MD5));
|
||||
if ($signType !== AuthConstant::API_SIGN_NAME_MD5) {
|
||||
throw new ValidationException('仅支持 MD5 签名');
|
||||
}
|
||||
|
||||
if (!$this->epaySignerManager->verify(
|
||||
$this->buildV1SignParams($payload),
|
||||
$signType,
|
||||
$sign,
|
||||
(string) $credential->api_key
|
||||
)) {
|
||||
throw new ValidationException('签名验证失败');
|
||||
}
|
||||
|
||||
$this->merchantService->ensureMerchantEnabled($merchantId);
|
||||
$typeCode = trim((string) ($payload['type'] ?? ''));
|
||||
$merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? ''));
|
||||
$subject = trim((string) ($payload['name'] ?? ''));
|
||||
$amount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0'));
|
||||
$paymentType = null;
|
||||
|
||||
if ($typeCode === '') {
|
||||
if (!$allowEmptyType) {
|
||||
throw new ValidationException('type 参数不能为空');
|
||||
}
|
||||
} else {
|
||||
$paymentType = $this->resolveSubmitPaymentType($typeCode);
|
||||
}
|
||||
|
||||
if ($merchantOrderNo === '') {
|
||||
throw new ValidationException('out_trade_no 参数不能为空');
|
||||
}
|
||||
if ($subject === '') {
|
||||
throw new ValidationException('name 参数不能为空');
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
throw new ValidationException('money 参数不合法');
|
||||
}
|
||||
|
||||
// 旧协议的展示字段统一交给 assembler,避免 submit / mapi / cashier 三处口径漂移。
|
||||
$orderFields = $this->orderInputAssembler->buildOrderFields($payload, $request, null, [
|
||||
'_protocol_version' => 'v1',
|
||||
]);
|
||||
|
||||
$normalized = [
|
||||
'merchant_id' => (int) ($payload['pid'] ?? 0),
|
||||
'merchant_order_no' => $merchantOrderNo,
|
||||
'pay_amount' => $amount,
|
||||
'subject' => (string) $orderFields['subject'],
|
||||
'body' => (string) $orderFields['body'],
|
||||
'notify_url' => (string) $orderFields['notify_url'],
|
||||
'return_url' => (string) $orderFields['return_url'],
|
||||
'client_ip' => (string) $orderFields['client_ip'],
|
||||
'device' => (string) $orderFields['device'],
|
||||
'ext_json' => (array) $orderFields['ext_json'],
|
||||
];
|
||||
|
||||
if ($paymentType) {
|
||||
$normalized['pay_type_id'] = (int) $paymentType->id;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤旧协议签名参数。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> 签名参数
|
||||
*/
|
||||
private function buildV1SignParams(array $payload): array
|
||||
{
|
||||
$params = $payload;
|
||||
unset($params['sign'], $params['sign_type'], $params['key']);
|
||||
|
||||
foreach ($params as $paramKey => $paramValue) {
|
||||
if ($paramValue === '' || $paramValue === null) {
|
||||
unset($params[$paramKey]);
|
||||
}
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析提交支付方式。
|
||||
*
|
||||
* 只接受显式传入且启用中的支付方式。
|
||||
*
|
||||
* @param string $typeCode 支付方式编码
|
||||
* @return PaymentType 支付方式模型
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function resolveSubmitPaymentType(string $typeCode): PaymentType
|
||||
{
|
||||
$typeCode = trim($typeCode);
|
||||
$paymentType = $this->paymentTypeService->findByCode($typeCode);
|
||||
if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new ValidationException('支付方式不支持');
|
||||
}
|
||||
|
||||
return $paymentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建旧版 MAPI 返回结构。
|
||||
*
|
||||
* 根据当前支付尝试结果,输出 payurl、qrcode 或 urlscheme 等旧协议字段。
|
||||
*
|
||||
* @param array $attempt 支付尝试结果
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
private function buildMapiResponse(array $attempt): array
|
||||
{
|
||||
/** @var PayOrder $payOrder */
|
||||
$payOrder = $attempt['pay_order'];
|
||||
$payParams = (array) ($attempt['pay_params'] ?? []);
|
||||
$normalizedPayload = (array) ($attempt['normalized_payload'] ?? []);
|
||||
$paymentPageUrl = (string) ($attempt['payment_page_url'] ?? $this->buildPaymentPageUrl((string) $payOrder->pay_no));
|
||||
$payNo = (string) $payOrder->pay_no;
|
||||
$response = ['code' => 1, 'msg' => '提交成功', 'trade_no' => $payNo];
|
||||
$device = strtolower(trim((string) ($normalizedPayload['device'] ?? '')));
|
||||
$type = strtolower(trim((string) ($payParams['type'] ?? '')));
|
||||
$resolved = $this->resolveV1PayResponse($payParams, $device, $paymentPageUrl, $type);
|
||||
|
||||
if ($resolved['field'] !== '') {
|
||||
$response[$resolved['field']] = $resolved['value'];
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析旧版 MAPI 的单字段返回体。
|
||||
*
|
||||
* 旧协议同一时刻只会返回 `payurl`、`qrcode`、`urlscheme` 中的一个。
|
||||
*
|
||||
* @param array<string, mixed> $payParams 插件返回参数
|
||||
* @param string $device 请求设备
|
||||
* @param string $paymentPageUrl 支付页地址
|
||||
* @param string $type 插件返回类型
|
||||
* @return array{field: string, value: string}
|
||||
*/
|
||||
private function resolveV1PayResponse(array $payParams, string $device, string $paymentPageUrl, string $type): array
|
||||
{
|
||||
if ($device === 'jump') {
|
||||
return ['field' => 'payurl', 'value' => $paymentPageUrl];
|
||||
}
|
||||
|
||||
if ($type === 'qrcode') {
|
||||
$qrcode = $this->stringifyValue($payParams['qrcode_url'] ?? '');
|
||||
if ($qrcode !== '') {
|
||||
return ['field' => 'qrcode', 'value' => $qrcode];
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'jsapi') {
|
||||
$urlscheme = $this->stringifyValue($payParams['order_string'] ?? '');
|
||||
if ($urlscheme !== '') {
|
||||
return ['field' => 'urlscheme', 'value' => $urlscheme];
|
||||
}
|
||||
}
|
||||
|
||||
return ['field' => 'payurl', 'value' => $paymentPageUrl];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前支付单格式化为旧版订单查询结构。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付订单
|
||||
* @param BizOrder|null $bizOrder 业务订单
|
||||
* @return array<string, mixed> 旧版订单结构
|
||||
*/
|
||||
private function formatEpayOrderRow(PayOrder $payOrder, ?BizOrder $bizOrder = null): array
|
||||
{
|
||||
$bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
$extJson = (array) (($bizOrder?->ext_json) ?? []);
|
||||
$merchantExt = (array) ($extJson['merchant'] ?? []);
|
||||
|
||||
return [
|
||||
'trade_no' => (string) $payOrder->pay_no,
|
||||
'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? $extJson['merchant_order_no'] ?? ''),
|
||||
'api_trade_no' => (string) ($payOrder->channel_trade_no ?: $payOrder->channel_order_no ?: ''),
|
||||
'type' => $this->resolvePaymentTypeCode((int) $payOrder->pay_type_id),
|
||||
'pid' => (int) $payOrder->merchant_id,
|
||||
'addtime' => FormatHelper::dateTime($payOrder->created_at),
|
||||
'endtime' => FormatHelper::dateTime($payOrder->paid_at),
|
||||
'name' => (string) ($bizOrder?->subject ?? $extJson['subject'] ?? ''),
|
||||
'money' => FormatHelper::amount((int) $payOrder->pay_amount),
|
||||
'status' => (int) $payOrder->status === TradeConstant::ORDER_STATUS_SUCCESS ? 1 : 0,
|
||||
'param' => $this->stringifyValue($merchantExt['param'] ?? ''),
|
||||
'buyer' => $this->stringifyValue($merchantExt['buyer'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析支付订单上下文。
|
||||
*
|
||||
* 优先按 `trade_no` 查找,其次按 `out_trade_no` 回退,并校验订单归属当前商户。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param array $payload 请求载荷
|
||||
* @return array{pay_order: PayOrder, biz_order: BizOrder|null}|null 上下文
|
||||
*/
|
||||
private function resolvePayOrderContext(int $merchantId, array $payload): ?array
|
||||
{
|
||||
$payNo = trim((string) ($payload['trade_no'] ?? ''));
|
||||
$merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? ''));
|
||||
$payOrder = null;
|
||||
$bizOrder = null;
|
||||
|
||||
if ($payNo !== '') {
|
||||
// 旧协议如果传了 trade_no,就优先按支付单号定位,命中率最高。
|
||||
$payOrder = $this->payOrderRepository->findByPayNo($payNo);
|
||||
if ($payOrder) {
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$payOrder && $merchantOrderNo !== '') {
|
||||
// 没有 trade_no 时,再按商户单号反查业务单和最新支付单。
|
||||
$bizOrder = $this->bizOrderRepository->findByMerchantAndOrderNo($merchantId, $merchantOrderNo);
|
||||
if ($bizOrder) {
|
||||
// 旧协议经常只传商户单号,这里拿业务单找到最新一笔支付单。
|
||||
$payOrder = $this->payOrderRepository->findLatestByBizNo((string) $bizOrder->biz_no);
|
||||
}
|
||||
}
|
||||
|
||||
// 旧协议有时会传到别家商户的单号,这里必须再次校验归属,避免跨商户读取。
|
||||
if (!$payOrder || (int) $payOrder->merchant_id !== $merchantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$bizOrder) {
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
}
|
||||
|
||||
return ['pay_order' => $payOrder, 'biz_order' => $bizOrder];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付方式 ID 解析支付方式编码。
|
||||
*
|
||||
* @param int $payTypeId 支付方式ID
|
||||
* @return string 支付方式编码
|
||||
*/
|
||||
private function resolvePaymentTypeCode(int $payTypeId): string
|
||||
{
|
||||
return $this->paymentTypeService->resolveCodeById($payTypeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将元金额转成分。
|
||||
*
|
||||
* @param string $money 金额字符串
|
||||
* @return int 金额分值,非法时返回 0
|
||||
*/
|
||||
private function parseMoneyToAmount(string $money): int
|
||||
{
|
||||
$money = trim($money);
|
||||
if ($money === '' || !preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$integer, $fraction] = array_pad(explode('.', $money, 2), 2, '');
|
||||
$fraction = str_pad($fraction, 2, '0');
|
||||
|
||||
// 旧协议金额按“元”传入,内部统一转成“分”处理,避免 float 精度漂移。
|
||||
return ((int) $integer) * 100 + (int) substr($fraction, 0, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化异常提示。
|
||||
*
|
||||
* @param Throwable $e 异常对象
|
||||
* @return string 错误提示
|
||||
*/
|
||||
private function normalizeErrorMessage(Throwable $e): string
|
||||
{
|
||||
return $e->getMessage() !== '' ? $e->getMessage() : '请求失败';
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建支付页地址。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @return string 支付页 URL
|
||||
*/
|
||||
private function buildPaymentPageUrl(string $payNo): string
|
||||
{
|
||||
return rtrim((string) sys_config('site_url'), '/') . '/payment/' . rawurlencode($payNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按支付载体生成浏览器响应。
|
||||
*
|
||||
* 页面跳转支付允许直接重定向或直接输出 HTML,其余载体统一回到平台支付页。
|
||||
*
|
||||
* @param array<string, mixed> $attempt 支付尝试结果
|
||||
* @return Response
|
||||
*/
|
||||
private function buildBrowserSubmitResponse(array $attempt): Response
|
||||
{
|
||||
$payParams = (array) ($attempt['pay_params'] ?? []);
|
||||
$payType = strtolower(trim((string) ($payParams['type'] ?? '')));
|
||||
$paymentPageUrl = (string) ($attempt['payment_page_url'] ?? '');
|
||||
|
||||
if (in_array($payType, ['jump', 'url', 'web', 'h5'], true)) {
|
||||
$jumpUrl = $this->resolveBrowserPayUrl($payParams);
|
||||
if ($jumpUrl !== '') {
|
||||
return redirect($jumpUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($payType, ['html', 'form'], true)) {
|
||||
$html = $this->resolveBrowserHtml($payParams);
|
||||
if ($html !== '') {
|
||||
return response($html, 200, [
|
||||
'Content-Type' => 'text/html; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($paymentPageUrl === '') {
|
||||
throw new ValidationException('支付页跳转地址生成失败');
|
||||
}
|
||||
|
||||
return redirect($paymentPageUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取浏览器跳转地址。
|
||||
*
|
||||
* @param array<string, mixed> $payParams 支付参数
|
||||
* @return string
|
||||
*/
|
||||
private function resolveBrowserPayUrl(array $payParams): string
|
||||
{
|
||||
foreach (['payurl', 'pay_url', 'url', 'redirect_url', 'mweb_url'] as $key) {
|
||||
$value = $this->stringifyValue($payParams[$key] ?? '');
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取浏览器可直接渲染的 HTML。
|
||||
*
|
||||
* @param array<string, mixed> $payParams 支付参数
|
||||
* @return string
|
||||
*/
|
||||
private function resolveBrowserHtml(array $payParams): string
|
||||
{
|
||||
foreach (['html', 'html_form', 'form_html'] as $key) {
|
||||
$value = $payParams[$key] ?? null;
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断插件返回的 success 标记。
|
||||
*
|
||||
* 如果插件未显式返回 `success`,则默认视为成功。
|
||||
*
|
||||
* @param array $pluginResult 插件结果
|
||||
* @return bool 插件是否通过
|
||||
*/
|
||||
private function isPluginSuccess(array $pluginResult): bool
|
||||
{
|
||||
return !array_key_exists('success', $pluginResult) || (bool) $pluginResult['success'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析退款渠道单号。
|
||||
*
|
||||
* @param array $pluginResult 插件结果
|
||||
* @param string $default 默认值
|
||||
* @return string 渠道退款单号
|
||||
*/
|
||||
private function resolveRefundChannelNo(array $pluginResult, string $default = ''): string
|
||||
{
|
||||
foreach (['chan_refund_no', 'refund_no', 'trade_no', 'out_request_no'] as $key) {
|
||||
if (array_key_exists($key, $pluginResult)) {
|
||||
$value = $this->stringifyValue($pluginResult[$key]);
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任意值规范化为字符串。
|
||||
*
|
||||
* @param array|object|bool|float|int|string|null $value 待转换值
|
||||
* @return string 规范化后的字符串
|
||||
*/
|
||||
private function stringifyValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
if (is_string($value)) {
|
||||
return trim($value);
|
||||
}
|
||||
if (is_int($value) || is_float($value) || is_bool($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
if (is_array($value) || is_object($value)) {
|
||||
// 复杂结构直接 JSON 化,保证旧协议回显时仍然可读。
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return $json !== false ? $json : '';
|
||||
}
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
}
|
||||
996
app/service/payment/epay/EpayV2ProtocolService.php
Normal file
996
app/service/payment/epay/EpayV2ProtocolService.php
Normal file
@@ -0,0 +1,996 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\epay;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\exception\ConflictException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\merchant\Merchant;
|
||||
use app\model\payment\BizOrder;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\model\payment\RefundOrder;
|
||||
use app\repository\account\balance\MerchantAccountRepository;
|
||||
use app\repository\merchant\credential\MerchantApiCredentialRepository;
|
||||
use app\repository\payment\trade\BizOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\repository\payment\trade\RefundOrderRepository;
|
||||
use app\service\merchant\MerchantService;
|
||||
use app\service\payment\order\PayOrderQueryService;
|
||||
use app\service\payment\order\PayOrderService;
|
||||
use app\service\payment\order\PaymentOrderInputAssembler;
|
||||
use app\service\payment\order\RefundQueryService;
|
||||
use app\service\payment\order\RefundService;
|
||||
use app\service\payment\transfer\TransferService;
|
||||
use app\service\payment\runtime\PaymentPluginManager;
|
||||
use app\service\payment\config\PaymentTypeService;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* ePay V2 协议服务。
|
||||
*/
|
||||
class EpayV2ProtocolService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantService $merchantService,
|
||||
protected MerchantApiCredentialRepository $merchantApiCredentialRepository,
|
||||
protected PaymentTypeService $paymentTypeService,
|
||||
protected PayOrderService $payOrderService,
|
||||
protected PayOrderQueryService $payOrderQueryService,
|
||||
protected RefundService $refundService,
|
||||
protected RefundQueryService $refundQueryService,
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected BizOrderRepository $bizOrderRepository,
|
||||
protected RefundOrderRepository $refundOrderRepository,
|
||||
protected MerchantAccountRepository $merchantAccountRepository,
|
||||
protected PaymentPluginManager $paymentPluginManager,
|
||||
protected TransferService $transferService,
|
||||
protected EpaySignerManager $signerManager,
|
||||
protected PaymentOrderInputAssembler $orderInputAssembler
|
||||
) {
|
||||
}
|
||||
|
||||
public function submit(array $payload, Request $request): Response
|
||||
{
|
||||
try {
|
||||
$typeCode = trim((string) ($payload['type'] ?? ''));
|
||||
if ($typeCode === '') {
|
||||
// `type` 为空时先回收银台,显式选完方式后再创建支付单。
|
||||
$attempt = $this->prepareCashierSubmit($payload, $request);
|
||||
$cashierUrl = (string) ($attempt['cashier_url'] ?? '');
|
||||
if ($cashierUrl === '') {
|
||||
throw new ValidationException('收银台跳转地址生成失败');
|
||||
}
|
||||
|
||||
return redirect($cashierUrl);
|
||||
}
|
||||
|
||||
return $this->buildBrowserSubmitResponse($this->preparePayAttempt($payload, $request, false));
|
||||
} catch (Throwable $e) {
|
||||
return json($this->signResponse([
|
||||
'code' => $this->resolveFailureCode($e),
|
||||
'msg' => $this->normalizeErrorMessage($e),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
public function create(array $payload, Request $request): array
|
||||
{
|
||||
try {
|
||||
$attempt = $this->preparePayAttempt($payload, $request, true);
|
||||
return $this->signResponse($this->buildCreateResponse($attempt));
|
||||
} catch (Throwable $e) {
|
||||
return $this->signResponse([
|
||||
'code' => $this->resolveFailureCode($e),
|
||||
'msg' => $this->normalizeErrorMessage($e),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function query(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchant = $this->authorizeMerchant($payload, true);
|
||||
$context = $this->resolvePayOrderContext((int) $merchant->id, $payload);
|
||||
if (!$context) {
|
||||
throw new ResourceNotFoundException('订单不存在');
|
||||
}
|
||||
|
||||
return $this->signResponse($this->buildOrderResponse($context['pay_order'], $context['biz_order']));
|
||||
} catch (Throwable $e) {
|
||||
return $this->signResponse([
|
||||
'code' => $this->resolveFailureCode($e),
|
||||
'msg' => $this->normalizeErrorMessage($e),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function refund(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchant = $this->authorizeMerchant($payload, true);
|
||||
$context = $this->resolvePayOrderContext((int) $merchant->id, $payload);
|
||||
if (!$context) {
|
||||
throw new ResourceNotFoundException('订单不存在');
|
||||
}
|
||||
|
||||
/** @var PayOrder $payOrder */
|
||||
$payOrder = $context['pay_order'];
|
||||
$refundAmount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0'));
|
||||
if ($refundAmount <= 0) {
|
||||
throw new ValidationException('money 参数不合法');
|
||||
}
|
||||
|
||||
$merchantRefundNo = trim((string) ($payload['out_refund_no'] ?? $payload['refund_no'] ?? ''));
|
||||
$refundOrder = $this->refundService->createRefund([
|
||||
'pay_no' => (string) $payOrder->pay_no,
|
||||
'merchant_refund_no' => $merchantRefundNo,
|
||||
'refund_amount' => $refundAmount,
|
||||
'reason' => trim((string) ($payload['reason'] ?? '')),
|
||||
]);
|
||||
|
||||
$plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true);
|
||||
$pluginResult = $plugin->refund([
|
||||
'order_id' => (string) $payOrder->pay_no,
|
||||
'pay_no' => (string) $payOrder->pay_no,
|
||||
'biz_no' => (string) $payOrder->biz_no,
|
||||
'chan_order_no' => (string) $payOrder->channel_order_no,
|
||||
'chan_trade_no' => (string) $payOrder->channel_trade_no,
|
||||
'out_trade_no' => (string) $payOrder->channel_order_no,
|
||||
'refund_no' => (string) $refundOrder->refund_no,
|
||||
'refund_amount' => $refundAmount,
|
||||
'refund_reason' => trim((string) ($payload['reason'] ?? '')),
|
||||
'extra' => (array) ($payOrder->ext_json ?? []),
|
||||
]);
|
||||
|
||||
if (!$this->isPluginSuccess($pluginResult)) {
|
||||
$this->refundService->markRefundFailed((string) $refundOrder->refund_no, [
|
||||
'failed_at' => $this->now(),
|
||||
'last_error' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败'),
|
||||
'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult),
|
||||
]);
|
||||
|
||||
throw new ValidationException((string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败'));
|
||||
}
|
||||
|
||||
$this->refundService->markRefundSuccess((string) $refundOrder->refund_no, [
|
||||
'succeeded_at' => $this->now(),
|
||||
'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult),
|
||||
]);
|
||||
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
return $this->signResponse($this->buildRefundResponse($refundOrder->refresh(), $payOrder->refresh(), $bizOrder));
|
||||
} catch (Throwable $e) {
|
||||
return $this->signResponse([
|
||||
'code' => $this->resolveFailureCode($e),
|
||||
'msg' => $this->normalizeErrorMessage($e),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function refundQuery(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchant = $this->authorizeMerchant($payload, true);
|
||||
$refundOrder = $this->resolveRefundOrder((int) $merchant->id, $payload);
|
||||
$payOrder = $this->payOrderRepository->findByPayNo((string) $refundOrder->pay_no);
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $refundOrder->biz_no);
|
||||
|
||||
return $this->signResponse($this->buildRefundResponse($refundOrder, $payOrder, $bizOrder));
|
||||
} catch (Throwable $e) {
|
||||
return $this->signResponse([
|
||||
'code' => $this->resolveFailureCode($e),
|
||||
'msg' => $this->normalizeErrorMessage($e),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function close(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchant = $this->authorizeMerchant($payload, true);
|
||||
$context = $this->resolvePayOrderContext((int) $merchant->id, $payload);
|
||||
if (!$context) {
|
||||
throw new ResourceNotFoundException('订单不存在');
|
||||
}
|
||||
|
||||
/** @var PayOrder $payOrder */
|
||||
$payOrder = $context['pay_order'];
|
||||
$currentStatus = (int) $payOrder->status;
|
||||
if ($currentStatus === TradeConstant::ORDER_STATUS_CLOSED) {
|
||||
return $this->signResponse([
|
||||
'code' => $this->successCode(),
|
||||
'msg' => 'success',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($currentStatus === TradeConstant::ORDER_STATUS_SUCCESS) {
|
||||
throw new ValidationException('订单已支付成功,不能关闭');
|
||||
}
|
||||
|
||||
if (TradeConstant::isOrderTerminalStatus($currentStatus)) {
|
||||
throw new ValidationException('订单已结束,不能关闭');
|
||||
}
|
||||
|
||||
$plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true);
|
||||
$pluginResult = $plugin->close([
|
||||
'order_id' => (string) $payOrder->pay_no,
|
||||
'pay_no' => (string) $payOrder->pay_no,
|
||||
'biz_no' => (string) $payOrder->biz_no,
|
||||
'chan_order_no' => (string) $payOrder->channel_order_no,
|
||||
'chan_trade_no' => (string) $payOrder->channel_trade_no,
|
||||
'out_trade_no' => (string) ($payOrder->channel_order_no ?: $payOrder->pay_no),
|
||||
'extra' => (array) ($payOrder->ext_json ?? []),
|
||||
]);
|
||||
|
||||
if (!$this->isPluginSuccess($pluginResult)) {
|
||||
throw new ValidationException((string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '渠道关单失败'));
|
||||
}
|
||||
|
||||
$closeReason = (string) ($pluginResult['msg'] ?? 'ePay V2 手动关闭');
|
||||
$this->payOrderService->closePayOrder((string) $payOrder->pay_no, [
|
||||
'closed_at' => $this->now(),
|
||||
'reason' => $closeReason,
|
||||
'ext_json' => [
|
||||
'plugin' => [
|
||||
'close_result' => $pluginResult,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
return $this->signResponse([
|
||||
'code' => $this->successCode(),
|
||||
'msg' => 'success',
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return $this->signResponse([
|
||||
'code' => $this->resolveFailureCode($e),
|
||||
'msg' => $this->normalizeErrorMessage($e),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function merchantInfo(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchant = $this->authorizeMerchant($payload, true);
|
||||
$account = $this->merchantAccountRepository->findByMerchantId((int) $merchant->id);
|
||||
$today = $this->nowDate();
|
||||
$yesterday = $this->yesterdayDate();
|
||||
|
||||
$orderQuery = $this->payOrderRepository->query()->where('merchant_id', (int) $merchant->id);
|
||||
|
||||
$totalOrders = (int) (clone $orderQuery)->count();
|
||||
$todayOrders = (int) (clone $orderQuery)->whereDate('created_at', $today)->count();
|
||||
$yesterdayOrders = (int) (clone $orderQuery)->whereDate('created_at', $yesterday)->count();
|
||||
$todayMoney = (int) (clone $orderQuery)->whereDate('created_at', $today)->sum('pay_amount');
|
||||
$yesterdayMoney = (int) (clone $orderQuery)->whereDate('created_at', $yesterday)->sum('pay_amount');
|
||||
|
||||
return $this->signResponse([
|
||||
'code' => $this->successCode(),
|
||||
'msg' => 'success',
|
||||
'pid' => (int) $merchant->id,
|
||||
'status' => (int) $merchant->status,
|
||||
'pay_status' => (int) ($merchant->pay_status ?? 1),
|
||||
'settle_status' => (int) ($merchant->settle_status ?? 1),
|
||||
'money' => $this->formatAmount((int) ($account->available_balance ?? 0)),
|
||||
'settle_type' => (int) ($merchant->settle_type ?? 4),
|
||||
'settle_account' => (string) ($merchant->settlement_account_no ?? ''),
|
||||
'settle_name' => (string) ($merchant->settlement_account_name ?? ''),
|
||||
'order_num' => $totalOrders,
|
||||
'order_num_today' => $todayOrders,
|
||||
'order_num_lastday' => $yesterdayOrders,
|
||||
'order_money_today' => $this->formatAmount($todayMoney),
|
||||
'order_money_lastday' => $this->formatAmount($yesterdayMoney),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return $this->signResponse([
|
||||
'code' => $this->resolveFailureCode($e),
|
||||
'msg' => $this->normalizeErrorMessage($e),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function merchantOrders(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchant = $this->authorizeMerchant($payload, true);
|
||||
$limit = min(50, max(1, (int) ($payload['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($payload['offset'] ?? 0));
|
||||
$page = (int) floor($offset / $limit) + 1;
|
||||
$filters = [];
|
||||
if (array_key_exists('status', $payload) && $payload['status'] !== '') {
|
||||
$filters['status'] = (int) $payload['status'];
|
||||
}
|
||||
|
||||
$result = $this->payOrderQueryService->paginate($filters, $page, $limit, (int) $merchant->id);
|
||||
|
||||
return $this->signResponse([
|
||||
'code' => $this->successCode(),
|
||||
'msg' => 'success',
|
||||
'data' => $result['list'] ?? [],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return $this->signResponse([
|
||||
'code' => $this->resolveFailureCode($e),
|
||||
'msg' => $this->normalizeErrorMessage($e),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function transferSubmit(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchant = $this->authorizeMerchant($payload, true);
|
||||
$data = $this->transferService->submit($merchant, $payload);
|
||||
|
||||
return $this->signResponse([
|
||||
'code' => $this->successCode(),
|
||||
'msg' => 'success',
|
||||
'status' => (int) ($data['status'] ?? 0),
|
||||
'biz_no' => (string) ($data['biz_no'] ?? ''),
|
||||
'out_biz_no' => (string) ($data['out_biz_no'] ?? ''),
|
||||
'orderid' => (string) ($data['orderid'] ?? ''),
|
||||
'paydate' => (string) ($data['paydate'] ?? ''),
|
||||
'cost_money' => (string) ($data['cost_money'] ?? ''),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return $this->signResponse([
|
||||
'code' => $this->resolveFailureCode($e),
|
||||
'msg' => $this->normalizeErrorMessage($e),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function transferQuery(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchant = $this->authorizeMerchant($payload, true);
|
||||
$data = $this->transferService->query($merchant, $payload);
|
||||
|
||||
return $this->signResponse([
|
||||
'code' => $this->successCode(),
|
||||
'msg' => 'success',
|
||||
] + $data);
|
||||
} catch (Throwable $e) {
|
||||
return $this->signResponse([
|
||||
'code' => $this->resolveFailureCode($e),
|
||||
'msg' => $this->normalizeErrorMessage($e),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function transferBalance(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchant = $this->authorizeMerchant($payload, true);
|
||||
$data = $this->transferService->balance($merchant);
|
||||
|
||||
return $this->signResponse([
|
||||
'code' => $this->successCode(),
|
||||
'msg' => 'success',
|
||||
] + $data);
|
||||
} catch (Throwable $e) {
|
||||
return $this->signResponse([
|
||||
'code' => $this->resolveFailureCode($e),
|
||||
'msg' => $this->normalizeErrorMessage($e),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预创建支付。
|
||||
*
|
||||
* @param array $payload 请求参数
|
||||
* @param Request $request 请求对象
|
||||
* @param bool $requireType 是否强制要求 type
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function preparePayAttempt(array $payload, Request $request, bool $requireType): array
|
||||
{
|
||||
$merchant = $this->authorizeMerchant($payload, true);
|
||||
$typeCode = trim((string) ($payload['type'] ?? ''));
|
||||
if ($requireType && $typeCode === '') {
|
||||
throw new ValidationException('type 不能为空');
|
||||
}
|
||||
|
||||
$paymentType = $this->resolvePaymentType($typeCode);
|
||||
$merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? ''));
|
||||
$subject = trim((string) ($payload['name'] ?? ''));
|
||||
$amount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0'));
|
||||
if ($merchantOrderNo === '') {
|
||||
throw new ValidationException('out_trade_no 不能为空');
|
||||
}
|
||||
if ($subject === '') {
|
||||
throw new ValidationException('name 不能为空');
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
throw new ValidationException('money 参数不合法');
|
||||
}
|
||||
|
||||
// V2 直连支付和收银台确认共用同一套字段归一化逻辑。
|
||||
$orderFields = $this->orderInputAssembler->buildOrderFields($payload, $request, null, [
|
||||
'_protocol_version' => 'v2',
|
||||
]);
|
||||
$normalized = [
|
||||
'merchant_id' => (int) $merchant->id,
|
||||
'merchant_order_no' => $merchantOrderNo,
|
||||
'pay_type_id' => (int) $paymentType->id,
|
||||
'pay_amount' => $amount,
|
||||
'subject' => (string) $orderFields['subject'],
|
||||
'body' => (string) $orderFields['body'],
|
||||
'notify_url' => (string) $orderFields['notify_url'],
|
||||
'return_url' => (string) $orderFields['return_url'],
|
||||
'client_ip' => (string) $orderFields['client_ip'],
|
||||
'device' => (string) $orderFields['device'],
|
||||
'channel_id' => (int) ($payload['channel_id'] ?? 0),
|
||||
'ext_json' => (array) $orderFields['ext_json'],
|
||||
];
|
||||
|
||||
$attempt = $this->payOrderService->preparePayAttempt($normalized);
|
||||
$payOrder = $attempt['pay_order'];
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'pay_order' => $payOrder,
|
||||
'payment_result' => $attempt['payment_result'] ?? [],
|
||||
'pay_params' => $attempt['pay_params'] ?? [],
|
||||
'payment_page_url' => $this->buildPaymentPageUrl((string) $payOrder->pay_no),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 预创建收银台业务单。
|
||||
*
|
||||
* @param array $payload 请求参数
|
||||
* @param Request $request 请求对象
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function prepareCashierSubmit(array $payload, Request $request): array
|
||||
{
|
||||
$merchant = $this->authorizeMerchant($payload, true);
|
||||
$merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? ''));
|
||||
$subject = trim((string) ($payload['name'] ?? ''));
|
||||
$amount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0'));
|
||||
if ($merchantOrderNo === '') {
|
||||
throw new ValidationException('out_trade_no 不能为空');
|
||||
}
|
||||
if ($subject === '') {
|
||||
throw new ValidationException('name 不能为空');
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
throw new ValidationException('money 参数不合法');
|
||||
}
|
||||
|
||||
// 收银台首屏只需要业务单上下文,不在这里创建支付单。
|
||||
$orderFields = $this->orderInputAssembler->buildOrderFields($payload, $request, null, [
|
||||
'_protocol_version' => 'v2',
|
||||
]);
|
||||
$normalized = [
|
||||
'merchant_id' => (int) $merchant->id,
|
||||
'merchant_order_no' => $merchantOrderNo,
|
||||
'pay_amount' => $amount,
|
||||
'subject' => (string) $orderFields['subject'],
|
||||
'body' => (string) $orderFields['body'],
|
||||
'notify_url' => (string) $orderFields['notify_url'],
|
||||
'return_url' => (string) $orderFields['return_url'],
|
||||
'client_ip' => (string) $orderFields['client_ip'],
|
||||
'device' => (string) $orderFields['device'],
|
||||
'ext_json' => (array) $orderFields['ext_json'],
|
||||
];
|
||||
|
||||
$result = $this->payOrderService->prepareCashierBizOrder($normalized);
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'biz_order' => $result['biz_order'] ?? null,
|
||||
'cashier_url' => (string) ($result['cashier_url'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建创建支付响应。
|
||||
*
|
||||
* @param array<string, mixed> $attempt 支付尝试结果
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildCreateResponse(array $attempt): array
|
||||
{
|
||||
/** @var PayOrder $payOrder */
|
||||
$payOrder = $attempt['pay_order'];
|
||||
$payParams = (array) ($attempt['pay_params'] ?? []);
|
||||
$paymentResult = (array) ($attempt['payment_result'] ?? []);
|
||||
|
||||
return [
|
||||
'code' => $this->successCode(),
|
||||
'msg' => 'success',
|
||||
'trade_no' => (string) $payOrder->pay_no,
|
||||
'pay_type' => strtolower(trim((string) ($payParams['type'] ?? $paymentResult['pay_type'] ?? 'qrcode'))),
|
||||
'pay_info' => $payParams,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析支付上下文。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param array $payload 请求参数
|
||||
* @return array{pay_order: PayOrder, biz_order: BizOrder|null}|null
|
||||
*/
|
||||
private function resolvePayOrderContext(int $merchantId, array $payload): ?array
|
||||
{
|
||||
$payNo = trim((string) ($payload['trade_no'] ?? ''));
|
||||
$merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? ''));
|
||||
$payOrder = null;
|
||||
$bizOrder = null;
|
||||
|
||||
if ($payNo !== '') {
|
||||
$payOrder = $this->payOrderRepository->findByPayNo($payNo);
|
||||
if ($payOrder) {
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$payOrder && $merchantOrderNo !== '') {
|
||||
$bizOrder = $this->bizOrderRepository->findByMerchantAndOrderNo($merchantId, $merchantOrderNo);
|
||||
if ($bizOrder) {
|
||||
$payOrder = $this->payOrderRepository->findLatestByBizNo((string) $bizOrder->biz_no);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$payOrder || (int) $payOrder->merchant_id !== $merchantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$bizOrder) {
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
}
|
||||
|
||||
return [
|
||||
'pay_order' => $payOrder,
|
||||
'biz_order' => $bizOrder,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析退款单。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param array $payload 请求参数
|
||||
* @return RefundOrder
|
||||
*/
|
||||
private function resolveRefundOrder(int $merchantId, array $payload): RefundOrder
|
||||
{
|
||||
$refundNo = trim((string) ($payload['refund_no'] ?? ''));
|
||||
$outRefundNo = trim((string) ($payload['out_refund_no'] ?? ''));
|
||||
|
||||
if ($refundNo !== '') {
|
||||
$refundOrder = $this->refundOrderRepository->findByRefundNo($refundNo);
|
||||
if (!$refundOrder || (int) $refundOrder->merchant_id !== $merchantId) {
|
||||
throw new ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]);
|
||||
}
|
||||
|
||||
return $refundOrder;
|
||||
}
|
||||
|
||||
if ($outRefundNo !== '') {
|
||||
$refundOrder = $this->refundOrderRepository->findByMerchantRefundNo($merchantId, $outRefundNo);
|
||||
if (!$refundOrder) {
|
||||
throw new ResourceNotFoundException('退款单不存在', ['out_refund_no' => $outRefundNo]);
|
||||
}
|
||||
|
||||
return $refundOrder;
|
||||
}
|
||||
|
||||
throw new ValidationException('refund_no/out_refund_no 不能为空');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单响应。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付单
|
||||
* @param BizOrder|null $bizOrder 业务单
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildOrderResponse(PayOrder $payOrder, ?BizOrder $bizOrder = null): array
|
||||
{
|
||||
$bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
$bizExtJson = (array) (($bizOrder?->ext_json) ?? []);
|
||||
$merchantExt = (array) ($bizExtJson['merchant'] ?? []);
|
||||
$refundAmount = (int) ($bizOrder?->refund_amount ?? 0);
|
||||
|
||||
return [
|
||||
'code' => $this->successCode(),
|
||||
'msg' => 'success',
|
||||
'trade_no' => (string) $payOrder->pay_no,
|
||||
'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? ''),
|
||||
'api_trade_no' => (string) ($payOrder->channel_trade_no ?: $payOrder->channel_order_no ?: ''),
|
||||
'type' => $this->resolvePaymentTypeCode((int) $payOrder->pay_type_id),
|
||||
'status' => $this->resolveEpayOrderStatus($payOrder, $refundAmount),
|
||||
'pid' => (int) $payOrder->merchant_id,
|
||||
'addtime' => FormatHelper::dateTime($payOrder->created_at),
|
||||
'endtime' => FormatHelper::dateTime($payOrder->paid_at),
|
||||
'name' => (string) ($bizOrder?->subject ?? ''),
|
||||
'money' => FormatHelper::amount((int) $payOrder->pay_amount),
|
||||
'refundmoney' => FormatHelper::amount($refundAmount),
|
||||
'param' => $this->stringifyValue($merchantExt['param'] ?? ''),
|
||||
'buyer' => $this->stringifyValue($merchantExt['buyer'] ?? ''),
|
||||
'clientip' => $this->stringifyValue($payOrder->client_ip ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建退款响应。
|
||||
*
|
||||
* @param RefundOrder $refundOrder 退款单
|
||||
* @param PayOrder|null $payOrder 支付单
|
||||
* @param BizOrder|null $bizOrder 业务单
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildRefundResponse(RefundOrder $refundOrder, ?PayOrder $payOrder = null, ?BizOrder $bizOrder = null): array
|
||||
{
|
||||
$payOrder ??= $this->payOrderRepository->findByPayNo((string) $refundOrder->pay_no);
|
||||
$bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $refundOrder->biz_no);
|
||||
|
||||
return [
|
||||
'code' => $this->successCode(),
|
||||
'msg' => 'success',
|
||||
'refund_no' => (string) $refundOrder->refund_no,
|
||||
'out_refund_no' => (string) $refundOrder->merchant_refund_no,
|
||||
'trade_no' => (string) $refundOrder->pay_no,
|
||||
'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? ''),
|
||||
'money' => FormatHelper::amount((int) $refundOrder->refund_amount),
|
||||
'reducemoney' => FormatHelper::amount((int) ($bizOrder?->refund_amount ?? 0)),
|
||||
'status' => (int) $refundOrder->status === TradeConstant::REFUND_STATUS_SUCCESS ? 1 : 0,
|
||||
'addtime' => FormatHelper::dateTime($refundOrder->created_at),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析支付方式。
|
||||
*
|
||||
* @param string $typeCode 支付方式编码
|
||||
* @return \app\model\payment\PaymentType
|
||||
*/
|
||||
private function resolvePaymentType(string $typeCode)
|
||||
{
|
||||
$typeCode = trim($typeCode);
|
||||
$paymentType = $this->paymentTypeService->findByCode($typeCode);
|
||||
if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new ValidationException('支付方式不支持');
|
||||
}
|
||||
|
||||
return $paymentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付方式 ID 解析支付方式编码。
|
||||
*
|
||||
* @param int $payTypeId 支付方式ID
|
||||
* @return string
|
||||
*/
|
||||
private function resolvePaymentTypeCode(int $payTypeId): string
|
||||
{
|
||||
return $this->paymentTypeService->resolveCodeById($payTypeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 ePay 查询状态。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付单
|
||||
* @param int $refundAmount 已退款金额
|
||||
* @return int
|
||||
*/
|
||||
private function resolveEpayOrderStatus(PayOrder $payOrder, int $refundAmount): int
|
||||
{
|
||||
if ((int) $payOrder->status === TradeConstant::ORDER_STATUS_SUCCESS) {
|
||||
return $refundAmount > 0 ? 2 : 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证商户并校验请求签名。
|
||||
*
|
||||
* @param array $payload 请求参数
|
||||
* @param bool $verifySignature 是否验签
|
||||
* @return Merchant
|
||||
*/
|
||||
private function authorizeMerchant(array $payload, bool $verifySignature): Merchant
|
||||
{
|
||||
$merchantId = (int) ($payload['pid'] ?? 0);
|
||||
if ($merchantId <= 0) {
|
||||
throw new ValidationException('pid 不能为空');
|
||||
}
|
||||
|
||||
$merchant = $this->merchantService->ensureMerchantEnabled($merchantId);
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
if (!$credential || (int) $credential->status !== AuthConstant::CREDENTIAL_STATUS_ENABLED) {
|
||||
throw new ValidationException('商户 API 凭证未开通');
|
||||
}
|
||||
|
||||
$publicKey = trim((string) ($credential->merchant_public_key ?? ''));
|
||||
if ($publicKey === '') {
|
||||
throw new ValidationException('商户 RSA 公钥未配置');
|
||||
}
|
||||
|
||||
if ($verifySignature) {
|
||||
$timestamp = (int) ($payload['timestamp'] ?? 0);
|
||||
if ($timestamp <= 0 || abs(time() - $timestamp) > (int) config('epay.v2.timestamp_ttl', 300)) {
|
||||
throw new ValidationException('timestamp 校验失败');
|
||||
}
|
||||
|
||||
$sign = trim((string) ($payload['sign'] ?? ''));
|
||||
if ($sign === '') {
|
||||
throw new ValidationException('sign 不能为空');
|
||||
}
|
||||
|
||||
$signType = $this->signerManager->normalizeSignType((string) ($payload['sign_type'] ?? AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA));
|
||||
$verifyPayload = $payload;
|
||||
unset($verifyPayload['sign'], $verifyPayload['sign_type']);
|
||||
|
||||
if (!$this->signerManager->verify($verifyPayload, $signType, $sign, $publicKey)) {
|
||||
throw new ValidationException('签名验证失败');
|
||||
}
|
||||
}
|
||||
|
||||
return $merchant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应签名。
|
||||
*
|
||||
* @param array<string, mixed> $data 响应数据
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function signResponse(array $data): array
|
||||
{
|
||||
$data['timestamp'] = (string) ($data['timestamp'] ?? time());
|
||||
$data['sign_type'] = $this->resolveResponseSignType();
|
||||
$privateKey = trim((string) config('epay.v2.platform_private_key', ''));
|
||||
if ($privateKey === '') {
|
||||
throw new ValidationException('平台 RSA 私钥未配置');
|
||||
}
|
||||
|
||||
$signParams = $data;
|
||||
unset($signParams['sign'], $signParams['sign_type']);
|
||||
$data['sign'] = $this->signerManager->sign($signParams, $data['sign_type'], $privateKey);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析响应签名类型。
|
||||
*
|
||||
* 响应始终回写文档约定的规范值,避免把内部别名暴露给商户。
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function resolveResponseSignType(): string
|
||||
{
|
||||
$signType = $this->signerManager->normalizeSignType((string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA));
|
||||
|
||||
return match ($signType) {
|
||||
AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA => AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
|
||||
AuthConstant::API_SIGN_NAME_MD5 => AuthConstant::API_SIGN_NAME_MD5,
|
||||
default => AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化错误信息。
|
||||
*
|
||||
* @param Throwable $e 异常
|
||||
* @return string
|
||||
*/
|
||||
private function normalizeErrorMessage(Throwable $e): string
|
||||
{
|
||||
return $e->getMessage() ?: '请求失败';
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 协议成功码。
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private function successCode(): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 V2 失败码。
|
||||
*
|
||||
* 文档只约定 `0` 为成功,其它值为失败;这里优先保留异常业务码,缺失时回退到 `1`。
|
||||
*
|
||||
* @param Throwable|null $e 异常对象
|
||||
* @return int
|
||||
*/
|
||||
private function resolveFailureCode(?Throwable $e = null): int
|
||||
{
|
||||
$code = (int) ($e?->getCode() ?? 0);
|
||||
|
||||
return $code === $this->successCode() ? 1 : $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额字符串转分。
|
||||
*
|
||||
* @param string $money 金额字符串
|
||||
* @return int
|
||||
*/
|
||||
private function parseMoneyToAmount(string $money): int
|
||||
{
|
||||
$money = trim($money);
|
||||
if ($money === '' || !preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$integer, $fraction] = array_pad(explode('.', $money, 2), 2, '');
|
||||
$fraction = str_pad($fraction, 2, '0');
|
||||
|
||||
return ((int) $integer) * 100 + (int) substr($fraction, 0, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析数字值。
|
||||
*
|
||||
* @param mixed $value 值
|
||||
* @return string
|
||||
*/
|
||||
private function stringifyValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return $json !== false ? $json : '';
|
||||
}
|
||||
|
||||
return trim((string) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前日期。
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function nowDate(): string
|
||||
{
|
||||
return FormatHelper::timestamp(time(), 'Y-m-d');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取昨日日期。
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function yesterdayDate(): string
|
||||
{
|
||||
return FormatHelper::timestamp(strtotime('-1 day'), 'Y-m-d');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析插件退款渠道单号。
|
||||
*
|
||||
* @param array $pluginResult 插件结果
|
||||
* @return string
|
||||
*/
|
||||
private function resolveRefundChannelNo(array $pluginResult): string
|
||||
{
|
||||
foreach (['chan_refund_no', 'refund_no', 'trade_no', 'out_request_no'] as $key) {
|
||||
$value = $this->stringifyValue($pluginResult[$key] ?? '');
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断插件是否成功。
|
||||
*
|
||||
* @param array $pluginResult 插件结果
|
||||
* @return bool
|
||||
*/
|
||||
private function isPluginSuccess(array $pluginResult): bool
|
||||
{
|
||||
return !array_key_exists('success', $pluginResult) || (bool) $pluginResult['success'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按支付载体生成浏览器响应。
|
||||
*
|
||||
* 页面跳转支付允许直接返回渠道跳转页或 HTML,其余情况回到平台支付页承载。
|
||||
*
|
||||
* @param array<string, mixed> $attempt 支付尝试结果
|
||||
* @return Response
|
||||
*/
|
||||
private function buildBrowserSubmitResponse(array $attempt): Response
|
||||
{
|
||||
$payParams = (array) ($attempt['pay_params'] ?? []);
|
||||
$paymentResult = (array) ($attempt['payment_result'] ?? []);
|
||||
$payType = strtolower(trim((string) ($payParams['type'] ?? $paymentResult['pay_type'] ?? '')));
|
||||
$paymentPageUrl = (string) ($attempt['payment_page_url'] ?? '');
|
||||
|
||||
if (in_array($payType, ['jump', 'url', 'web', 'h5'], true)) {
|
||||
$jumpUrl = $this->resolveBrowserPayUrl($payParams);
|
||||
if ($jumpUrl !== '') {
|
||||
return redirect($jumpUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($payType, ['html', 'form'], true)) {
|
||||
$html = $this->resolveBrowserHtml($payParams);
|
||||
if ($html !== '') {
|
||||
return response($html, 200, [
|
||||
'Content-Type' => 'text/html; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($paymentPageUrl === '') {
|
||||
throw new ValidationException('支付页跳转地址生成失败');
|
||||
}
|
||||
|
||||
return redirect($paymentPageUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建支付页地址。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @return string
|
||||
*/
|
||||
private function buildPaymentPageUrl(string $payNo): string
|
||||
{
|
||||
return rtrim((string) sys_config('site_url'), '/') . '/payment/' . rawurlencode($payNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取浏览器跳转地址。
|
||||
*
|
||||
* @param array<string, mixed> $payParams 支付参数
|
||||
* @return string
|
||||
*/
|
||||
private function resolveBrowserPayUrl(array $payParams): string
|
||||
{
|
||||
foreach (['payurl', 'pay_url', 'url', 'redirect_url', 'mweb_url'] as $key) {
|
||||
$value = $this->stringifyValue($payParams[$key] ?? '');
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取浏览器可直接渲染的 HTML。
|
||||
*
|
||||
* @param array<string, mixed> $payParams 支付参数
|
||||
* @return string
|
||||
*/
|
||||
private function resolveBrowserHtml(array $payParams): string
|
||||
{
|
||||
foreach (['html', 'html_form', 'form_html'] as $key) {
|
||||
$value = $payParams[$key] ?? null;
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
38
app/service/payment/epay/Md5Signer.php
Normal file
38
app/service/payment/epay/Md5Signer.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\payment\epay;
|
||||
|
||||
/**
|
||||
* ePay MD5 签名实现。
|
||||
*/
|
||||
class Md5Signer extends EpaySignerAbstract implements EpaySignerInterface
|
||||
{
|
||||
/**
|
||||
* 生成 MD5 签名。
|
||||
*
|
||||
* @param array<string, mixed> $params 待签名参数
|
||||
* @param string $key 密钥
|
||||
* @return string 签名结果
|
||||
*/
|
||||
public function sign(array $params, string $key): string
|
||||
{
|
||||
$content = $this->buildContent($params);
|
||||
return md5($content . $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 MD5 签名。
|
||||
*
|
||||
* @param array<string, mixed> $params 待验签参数
|
||||
* @param string $sign 签名值
|
||||
* @param string $key 密钥
|
||||
* @return bool 是否通过
|
||||
*/
|
||||
public function verify(array $params, string $sign, string $key): bool
|
||||
{
|
||||
$expected = $this->sign($params, $key);
|
||||
return hash_equals(strtolower($expected), strtolower(trim($sign)));
|
||||
}
|
||||
}
|
||||
68
app/service/payment/epay/RsaSigner.php
Normal file
68
app/service/payment/epay/RsaSigner.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\service\payment\epay;
|
||||
|
||||
use app\exception\PaymentException;
|
||||
|
||||
/**
|
||||
* ePay RSA 签名实现。
|
||||
*/
|
||||
class RsaSigner extends EpaySignerAbstract implements EpaySignerInterface
|
||||
{
|
||||
/**
|
||||
* 生成 RSA 签名。
|
||||
*
|
||||
* @param array<string, mixed> $params 待签名参数
|
||||
* @param string $key 私钥
|
||||
* @return string 签名结果
|
||||
*/
|
||||
public function sign(array $params, string $key): string
|
||||
{
|
||||
$content = $this->buildContent($params);
|
||||
$privateKey = $this->normalizePem($key, 'PRIVATE');
|
||||
if ($privateKey === '') {
|
||||
throw new PaymentException('RSA 私钥不能为空', 40200);
|
||||
}
|
||||
|
||||
$resource = openssl_pkey_get_private($privateKey);
|
||||
if ($resource === false) {
|
||||
throw new PaymentException('签名失败,RSA 私钥无效', 40200);
|
||||
}
|
||||
|
||||
$result = openssl_sign($content, $signature, $resource, OPENSSL_ALGO_SHA256);
|
||||
|
||||
if ($result !== true) {
|
||||
throw new PaymentException('RSA 签名失败', 40200);
|
||||
}
|
||||
|
||||
return base64_encode($signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 RSA 签名。
|
||||
*
|
||||
* @param array<string, mixed> $params 待验签参数
|
||||
* @param string $sign 签名值
|
||||
* @param string $key 公钥
|
||||
* @return bool 是否通过
|
||||
*/
|
||||
public function verify(array $params, string $sign, string $key): bool
|
||||
{
|
||||
$content = $this->buildContent($params);
|
||||
$publicKey = $this->normalizePem($key, 'PUBLIC');
|
||||
if ($publicKey === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resource = openssl_pkey_get_public($publicKey);
|
||||
if ($resource === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = openssl_verify($content, base64_decode(trim($sign), true) ?: '', $resource, OPENSSL_ALGO_SHA256);
|
||||
|
||||
return $result === 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user