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

@@ -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
);
}
}

View 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;
}

View 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,
};
}
}

View 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;
}
}

View 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 '';
}
}

View 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)));
}
}

View 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;
}
}