mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-05-09 02:14:53 +08:00
1. 维护代码健壮
2. 更新项目结构文档
This commit is contained in:
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace app\command;
|
||||
|
||||
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\http\api\controller\adapter\EpayController;
|
||||
use app\http\api\controller\epay\EpayV1Controller;
|
||||
use app\model\merchant\Merchant;
|
||||
use app\model\merchant\MerchantApiCredential;
|
||||
use app\model\payment\PaymentChannel;
|
||||
@@ -20,7 +22,7 @@ use app\repository\payment\config\PaymentPollGroupRepository;
|
||||
use app\repository\payment\config\PaymentTypeRepository;
|
||||
use app\repository\payment\trade\BizOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use RuntimeException;
|
||||
use app\exception\CommandException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -46,11 +48,15 @@ class EpayMapiTest extends Command
|
||||
$this
|
||||
->setDescription('自动读取真实商户、路由和插件配置,测试 ePay mapi 是否能正常调用并返回可用结果。')
|
||||
->addOption('live', null, InputOption::VALUE_NONE, '使用真实数据库并发起实际 mapi 调用')
|
||||
->addOption('skip-api', null, InputOption::VALUE_NONE, '跳过 mapi 成功后的 V1 api.php 查询接口校验')
|
||||
->addOption('merchant-id', null, InputOption::VALUE_OPTIONAL, '指定商户 ID')
|
||||
->addOption('merchant-no', null, InputOption::VALUE_OPTIONAL, '指定商户号')
|
||||
->addOption('type', null, InputOption::VALUE_OPTIONAL, '支付方式编码,默认 alipay', 'alipay')
|
||||
->addOption('money', null, InputOption::VALUE_OPTIONAL, '支付金额,单位元,默认 1.00', '1.00')
|
||||
->addOption('device', null, InputOption::VALUE_OPTIONAL, '设备类型,默认 pc', 'pc')
|
||||
->addOption('refund-trade-no', null, InputOption::VALUE_OPTIONAL, '指定需要退款的 V1 平台订单号')
|
||||
->addOption('refund-out-trade-no', null, InputOption::VALUE_OPTIONAL, '指定需要退款的商户订单号')
|
||||
->addOption('refund-money', null, InputOption::VALUE_OPTIONAL, '退款金额,单位元')
|
||||
->addOption('out-trade-no', null, InputOption::VALUE_OPTIONAL, '商户订单号,默认自动生成');
|
||||
}
|
||||
|
||||
@@ -116,8 +122,8 @@ class EpayMapiTest extends Command
|
||||
$device,
|
||||
$siteUrl
|
||||
);
|
||||
/** @var EpayController $controller */
|
||||
$controller = $this->resolve(EpayController::class);
|
||||
/** @var EpayV1Controller $controller */
|
||||
$controller = $this->resolve(EpayV1Controller::class);
|
||||
$response = $controller->mapi($this->buildRequest($payload));
|
||||
$responseData = $this->decodeResponse($response->rawBody());
|
||||
$orderSnapshot = $this->loadOrderSnapshot((int) $merchant->id, $outTradeNo);
|
||||
@@ -125,7 +131,29 @@ class EpayMapiTest extends Command
|
||||
$this->writeAttempt($output, $payload, $responseData, $orderSnapshot);
|
||||
|
||||
$status = $this->classifyAttempt($responseData, $orderSnapshot);
|
||||
return $status === 'pass' ? self::SUCCESS : self::FAILURE;
|
||||
if ($status !== 'pass') {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->optionBool($input, 'skip-api', false)) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$apiPassed = $this->runCompatibleApiChecks(
|
||||
$output,
|
||||
$merchant,
|
||||
$credential,
|
||||
$outTradeNo,
|
||||
(string) ($responseData['trade_no'] ?? '')
|
||||
);
|
||||
|
||||
if (!$apiPassed) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$refundPassed = $this->runOptionalRefundCheck($input, $output, $merchant, $credential);
|
||||
|
||||
return $refundPassed ? self::SUCCESS : self::FAILURE;
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>[失败]</error> ' . $this->formatThrowable($e));
|
||||
|
||||
@@ -140,7 +168,7 @@ class EpayMapiTest extends Command
|
||||
*/
|
||||
private function ensureDependencies(): void
|
||||
{
|
||||
$this->resolve(EpayController::class);
|
||||
$this->resolve(EpayV1Controller::class);
|
||||
$this->resolve(MerchantRepository::class);
|
||||
$this->resolve(MerchantApiCredentialRepository::class);
|
||||
$this->resolve(PaymentTypeRepository::class);
|
||||
@@ -159,26 +187,26 @@ class EpayMapiTest extends Command
|
||||
* @param string $merchantNoOption 商户编号选项
|
||||
* @param string $typeCode 支付方式编码
|
||||
* @return array 上下文数据
|
||||
* @throws RuntimeException
|
||||
* @throws CommandException
|
||||
*/
|
||||
private function discoverContext(int $merchantIdOption, string $merchantNoOption, string $typeCode): array
|
||||
{
|
||||
/** @var PaymentTypeRepository $paymentTypeRepository */
|
||||
$paymentTypeRepository = $this->resolve(PaymentTypeRepository::class);
|
||||
$paymentType = $paymentTypeRepository->findByCode($typeCode);
|
||||
if (!$paymentType || (int) $paymentType->status !== 1) {
|
||||
throw new RuntimeException('未找到可用的支付方式: ' . $typeCode);
|
||||
if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new CommandException('未找到可用的支付方式: ' . $typeCode);
|
||||
}
|
||||
|
||||
$merchant = $this->pickMerchant($merchantIdOption, $merchantNoOption);
|
||||
$credential = $this->findMerchantCredential((int) $merchant->id);
|
||||
if (!$credential) {
|
||||
throw new RuntimeException('商户未开通有效 API 凭证: ' . $merchant->merchant_no);
|
||||
throw new CommandException('商户未开通有效 API 凭证: ' . $merchant->merchant_no);
|
||||
}
|
||||
|
||||
$route = $this->buildRouteSnapshot((int) $merchant->group_id, (int) $paymentType->id);
|
||||
if ($route === null) {
|
||||
throw new RuntimeException('商户未配置可用路由: ' . $merchant->merchant_no);
|
||||
throw new CommandException('商户未配置可用路由: ' . $merchant->merchant_no);
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -195,7 +223,7 @@ class EpayMapiTest extends Command
|
||||
* @param int $merchantIdOption 商户 ID 选项
|
||||
* @param string $merchantNoOption 商户编号选项
|
||||
* @return Merchant 商户记录
|
||||
* @throws RuntimeException
|
||||
* @throws CommandException
|
||||
*/
|
||||
private function pickMerchant(int $merchantIdOption, string $merchantNoOption): Merchant
|
||||
{
|
||||
@@ -204,12 +232,12 @@ class EpayMapiTest extends Command
|
||||
|
||||
if ($merchantIdOption > 0) {
|
||||
$merchant = $merchantRepository->find($merchantIdOption);
|
||||
if (!$merchant || (int) $merchant->status !== 1) {
|
||||
throw new RuntimeException('指定商户不存在或未启用: ' . $merchantIdOption);
|
||||
if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new CommandException('指定商户不存在或未启用: ' . $merchantIdOption);
|
||||
}
|
||||
|
||||
if ($merchantNoOption !== '' && (string) $merchant->merchant_no !== $merchantNoOption) {
|
||||
throw new RuntimeException('merchant-id 和 merchant-no 不匹配。');
|
||||
throw new CommandException('商户ID与商户号不匹配。');
|
||||
}
|
||||
|
||||
return $merchant;
|
||||
@@ -217,8 +245,8 @@ class EpayMapiTest extends Command
|
||||
|
||||
if ($merchantNoOption !== '') {
|
||||
$merchant = $merchantRepository->findByMerchantNo($merchantNoOption);
|
||||
if (!$merchant || (int) $merchant->status !== 1) {
|
||||
throw new RuntimeException('指定商户不存在或未启用: ' . $merchantNoOption);
|
||||
if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new CommandException('指定商户不存在或未启用: ' . $merchantNoOption);
|
||||
}
|
||||
|
||||
return $merchant;
|
||||
@@ -226,7 +254,7 @@ class EpayMapiTest extends Command
|
||||
|
||||
$merchant = $merchantRepository->enabledList(['id', 'merchant_no', 'merchant_name', 'group_id', 'status'])->first();
|
||||
if (!$merchant) {
|
||||
throw new RuntimeException('未找到启用中的真实商户。');
|
||||
throw new CommandException('未找到启用中的真实商户。');
|
||||
}
|
||||
|
||||
return $merchant;
|
||||
@@ -243,7 +271,7 @@ class EpayMapiTest extends Command
|
||||
/** @var MerchantApiCredentialRepository $repository */
|
||||
$repository = $this->resolve(MerchantApiCredentialRepository::class);
|
||||
$credential = $repository->findByMerchantId($merchantId);
|
||||
if (!$credential || (int) $credential->status !== 1) {
|
||||
if (!$credential || (int) $credential->status !== AuthConstant::CREDENTIAL_STATUS_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -274,7 +302,7 @@ class EpayMapiTest extends Command
|
||||
}
|
||||
|
||||
$pollGroup = $pollGroupRepository->find((int) $bind->poll_group_id);
|
||||
if (!$pollGroup || (int) $pollGroup->status !== 1) {
|
||||
if (!$pollGroup || (int) $pollGroup->status !== CommonConstant::STATUS_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -351,7 +379,7 @@ class EpayMapiTest extends Command
|
||||
'money' => $money,
|
||||
'clientip' => '127.0.0.1',
|
||||
'device' => $device,
|
||||
'sign_type' => 'MD5',
|
||||
'sign_type' => AuthConstant::API_SIGN_NAME_MD5,
|
||||
];
|
||||
$payload['sign'] = $this->signPayload($payload, (string) $credential->api_key);
|
||||
|
||||
@@ -452,7 +480,7 @@ class EpayMapiTest extends Command
|
||||
(string) ($responseData['code'] ?? ''),
|
||||
(string) ($responseData['msg'] ?? '')
|
||||
));
|
||||
foreach (['trade_no', 'payurl', 'origin_payurl', 'qrcode', 'urlscheme'] as $key) {
|
||||
foreach (['trade_no', 'payurl', 'qrcode', 'urlscheme'] as $key) {
|
||||
if (!isset($responseData[$key]) || $responseData[$key] === '') {
|
||||
continue;
|
||||
}
|
||||
@@ -493,7 +521,8 @@ class EpayMapiTest extends Command
|
||||
));
|
||||
|
||||
$extJson = (array) ($payOrder['ext_json'] ?? []);
|
||||
$summary = $this->summarizePayParamsSnapshot((array) ($extJson['pay_params_snapshot'] ?? []));
|
||||
$presentation = (array) ($extJson['presentation'] ?? []);
|
||||
$summary = $this->summarizePayParamsSnapshot((array) ($presentation['params_snapshot'] ?? []));
|
||||
if ($summary !== []) {
|
||||
$output->writeln(' 插件返回:');
|
||||
$output->writeln(' ' . $this->formatJson($summary));
|
||||
@@ -534,7 +563,6 @@ class EpayMapiTest extends Command
|
||||
break;
|
||||
case 'url':
|
||||
$summary['payurl'] = $this->stringifyValue($snapshot['payurl'] ?? '');
|
||||
$summary['origin_payurl'] = $this->stringifyValue($snapshot['origin_payurl'] ?? '');
|
||||
break;
|
||||
default:
|
||||
if (isset($snapshot['raw']) && is_array($snapshot['raw'])) {
|
||||
@@ -627,16 +655,17 @@ class EpayMapiTest extends Command
|
||||
* @param array $payload 请求载荷
|
||||
* @return Request 请求对象
|
||||
*/
|
||||
private function buildRequest(array $payload): Request
|
||||
private function buildRequest(array $payload, string $path = '/mapi.php'): Request
|
||||
{
|
||||
$body = http_build_query($payload, '', '&', PHP_QUERY_RFC1738);
|
||||
$siteUrl = $this->resolveSiteUrl();
|
||||
$host = parse_url($siteUrl, PHP_URL_HOST) ?: 'localhost';
|
||||
$port = parse_url($siteUrl, PHP_URL_PORT);
|
||||
$hostHeader = $port ? sprintf('%s:%s', $host, $port) : $host;
|
||||
$path = '/' . ltrim($path, '/');
|
||||
|
||||
$rawRequest = implode("\r\n", [
|
||||
'POST /mapi.php HTTP/1.1',
|
||||
'POST ' . $path . ' HTTP/1.1',
|
||||
'Host: ' . $hostHeader,
|
||||
'Content-Type: application/x-www-form-urlencoded; charset=UTF-8',
|
||||
'Content-Length: ' . strlen($body),
|
||||
@@ -679,6 +708,182 @@ class EpayMapiTest extends Command
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 mapi 成功后继续校验 V1 api.php 查询接口。
|
||||
*
|
||||
* @param OutputInterface $output 输出对象
|
||||
* @param Merchant $merchant 商户
|
||||
* @param MerchantApiCredential $credential 商户 API 凭证
|
||||
* @param string $merchantOrderNo 商户订单号
|
||||
* @param string $tradeNo 平台订单号
|
||||
* @return bool 是否全部通过
|
||||
*/
|
||||
private function runCompatibleApiChecks(
|
||||
OutputInterface $output,
|
||||
Merchant $merchant,
|
||||
MerchantApiCredential $credential,
|
||||
string $merchantOrderNo,
|
||||
string $tradeNo
|
||||
): bool {
|
||||
$checks = [
|
||||
'query' => [
|
||||
'payload' => [
|
||||
'act' => 'query',
|
||||
'pid' => (int) $merchant->id,
|
||||
'key' => (string) $credential->api_key,
|
||||
],
|
||||
'assert' => function (array $responseData) use ($merchant, $credential): bool {
|
||||
return (int) ($responseData['code'] ?? 0) === 1
|
||||
&& (int) ($responseData['pid'] ?? 0) === (int) $merchant->id
|
||||
&& (string) ($responseData['key'] ?? '') === (string) $credential->api_key;
|
||||
},
|
||||
],
|
||||
'order(out_trade_no)' => [
|
||||
'payload' => [
|
||||
'act' => 'order',
|
||||
'pid' => (int) $merchant->id,
|
||||
'key' => (string) $credential->api_key,
|
||||
'out_trade_no' => $merchantOrderNo,
|
||||
],
|
||||
'assert' => function (array $responseData) use ($merchantOrderNo): bool {
|
||||
return (int) ($responseData['code'] ?? 0) === 1
|
||||
&& (string) ($responseData['out_trade_no'] ?? '') === $merchantOrderNo;
|
||||
},
|
||||
],
|
||||
'orders' => [
|
||||
'payload' => [
|
||||
'act' => 'orders',
|
||||
'pid' => (int) $merchant->id,
|
||||
'key' => (string) $credential->api_key,
|
||||
'limit' => 5,
|
||||
'page' => 1,
|
||||
],
|
||||
'assert' => static function (array $responseData): bool {
|
||||
return (int) ($responseData['code'] ?? 0) === 1 && is_array($responseData['data'] ?? null);
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
if ($tradeNo !== '') {
|
||||
$checks['order(trade_no)'] = [
|
||||
'payload' => [
|
||||
'act' => 'order',
|
||||
'pid' => (int) $merchant->id,
|
||||
'key' => (string) $credential->api_key,
|
||||
'trade_no' => $tradeNo,
|
||||
],
|
||||
'assert' => function (array $responseData) use ($tradeNo): bool {
|
||||
return (int) ($responseData['code'] ?? 0) === 1
|
||||
&& (string) ($responseData['trade_no'] ?? '') === $tradeNo;
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
$allPassed = true;
|
||||
foreach ($checks as $name => $check) {
|
||||
$responseData = $this->callCompatibleApi($check['payload']);
|
||||
$passed = (bool) ($check['assert'])($responseData);
|
||||
$allPassed = $allPassed && $passed;
|
||||
$this->writeCompatibleApiAttempt($output, $name, $responseData, $passed);
|
||||
}
|
||||
|
||||
return $allPassed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据命令行参数决定是否追加 V1 退款接口校验。
|
||||
*
|
||||
* @param InputInterface $input 命令输入
|
||||
* @param OutputInterface $output 输出对象
|
||||
* @param Merchant $merchant 商户
|
||||
* @param MerchantApiCredential $credential 商户 API 凭证
|
||||
* @return bool 是否通过
|
||||
* @throws CommandException
|
||||
*/
|
||||
private function runOptionalRefundCheck(
|
||||
InputInterface $input,
|
||||
OutputInterface $output,
|
||||
Merchant $merchant,
|
||||
MerchantApiCredential $credential
|
||||
): bool {
|
||||
$tradeNo = trim($this->optionString($input, 'refund-trade-no', ''));
|
||||
$merchantOrderNo = trim($this->optionString($input, 'refund-out-trade-no', ''));
|
||||
if ($tradeNo === '' && $merchantOrderNo === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'act' => 'refund',
|
||||
'pid' => (int) $merchant->id,
|
||||
'key' => (string) $credential->api_key,
|
||||
'money' => $this->normalizeMoney($this->optionString($input, 'refund-money', '1.00')),
|
||||
];
|
||||
if ($tradeNo !== '') {
|
||||
$payload['trade_no'] = $tradeNo;
|
||||
}
|
||||
if ($merchantOrderNo !== '') {
|
||||
$payload['out_trade_no'] = $merchantOrderNo;
|
||||
}
|
||||
|
||||
$responseData = $this->callCompatibleApi($payload);
|
||||
$passed = (int) ($responseData['code'] ?? 0) === 1;
|
||||
$this->writeCompatibleApiAttempt($output, 'refund', $responseData, $passed);
|
||||
|
||||
return $passed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 V1 api.php 接口。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array 响应数据
|
||||
*/
|
||||
private function callCompatibleApi(array $payload): array
|
||||
{
|
||||
/** @var EpayV1Controller $controller */
|
||||
$controller = $this->resolve(EpayV1Controller::class);
|
||||
$response = $controller->api($this->buildRequest($payload, '/api.php'));
|
||||
|
||||
return $this->decodeResponse($response->rawBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出 V1 api.php 接口校验结果。
|
||||
*
|
||||
* @param OutputInterface $output 输出对象
|
||||
* @param string $name 检查名称
|
||||
* @param array $responseData 响应数据
|
||||
* @param bool $passed 是否通过
|
||||
* @return void
|
||||
*/
|
||||
private function writeCompatibleApiAttempt(
|
||||
OutputInterface $output,
|
||||
string $name,
|
||||
array $responseData,
|
||||
bool $passed
|
||||
): void {
|
||||
$label = $passed ? '<info>[通过]</info>' : '<error>[失败]</error>';
|
||||
$output->writeln(sprintf(
|
||||
'%s api(%s) - code=%s msg=%s',
|
||||
$label,
|
||||
$name,
|
||||
(string) ($responseData['code'] ?? ''),
|
||||
(string) ($responseData['msg'] ?? '')
|
||||
));
|
||||
|
||||
foreach (['pid', 'trade_no', 'out_trade_no', 'orders', 'order_today', 'order_lastday'] as $key) {
|
||||
if (!array_key_exists($key, $responseData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$output->writeln(sprintf(' 返回: %s=%s', $key, $this->stringifyValue($responseData[$key])));
|
||||
}
|
||||
|
||||
if (isset($responseData['data']) && is_array($responseData['data'])) {
|
||||
$output->writeln(sprintf(' 数据条数: %d', count($responseData['data'])));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析站点地址。
|
||||
*
|
||||
@@ -695,7 +900,7 @@ class EpayMapiTest extends Command
|
||||
*
|
||||
* @param string $money 金额
|
||||
* @return string 金额字符串
|
||||
* @throws RuntimeException
|
||||
* @throws CommandException
|
||||
*/
|
||||
private function normalizeMoney(string $money): string
|
||||
{
|
||||
@@ -705,7 +910,7 @@ class EpayMapiTest extends Command
|
||||
}
|
||||
|
||||
if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) {
|
||||
throw new RuntimeException('money 参数不合法: ' . $money);
|
||||
throw new CommandException('money 参数不合法: ' . $money);
|
||||
}
|
||||
|
||||
return number_format((float) $money, 2, '.', '');
|
||||
@@ -809,8 +1014,7 @@ class EpayMapiTest extends Command
|
||||
{
|
||||
$data = method_exists($e, 'getData') ? $e->getData() : [];
|
||||
$suffix = is_array($data) && $data !== [] ? ' ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : '';
|
||||
|
||||
return $e::class . ': ' . $e->getMessage() . $suffix;
|
||||
return $e::class . ':' . $e->getMessage() . $suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -866,18 +1070,18 @@ class EpayMapiTest extends Command
|
||||
*
|
||||
* @param string $class 类名
|
||||
* @return object 对象实例
|
||||
* @throws RuntimeException
|
||||
* @throws CommandException
|
||||
*/
|
||||
private function resolve(string $class): object
|
||||
{
|
||||
try {
|
||||
$instance = container_make($class, []);
|
||||
} catch (\Throwable $e) {
|
||||
throw new RuntimeException("无法解析 {$class}: " . $e->getMessage(), 0, $e);
|
||||
throw new CommandException("无法解析 {$class}。", 0, $e);
|
||||
}
|
||||
|
||||
if (!is_object($instance)) {
|
||||
throw new RuntimeException("解析后的 {$class} 不是对象。");
|
||||
throw new CommandException("解析后的 {$class} 不是对象。");
|
||||
}
|
||||
|
||||
return $instance;
|
||||
|
||||
1314
app/command/EpayMockChainTest.php
Normal file
1314
app/command/EpayMockChainTest.php
Normal file
File diff suppressed because it is too large
Load Diff
1217
app/command/EpayV2ApiTest.php
Normal file
1217
app/command/EpayV2ApiTest.php
Normal file
File diff suppressed because it is too large
Load Diff
490
app/command/EpayV2Bootstrap.php
Normal file
490
app/command/EpayV2Bootstrap.php
Normal file
@@ -0,0 +1,490 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\exception\CommandException;
|
||||
use app\model\merchant\Merchant;
|
||||
use app\repository\merchant\base\MerchantRepository;
|
||||
use app\repository\merchant\credential\MerchantApiCredentialRepository;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* ePay V2 开发联调初始化命令。
|
||||
*
|
||||
* 负责生成平台 RSA 密钥和测试商户 RSA 密钥,并把商户公钥写回数据库。
|
||||
*/
|
||||
#[AsCommand('epay:v2-bootstrap', '初始化 ePay V2 开发联调密钥')]
|
||||
class EpayV2Bootstrap extends Command
|
||||
{
|
||||
/**
|
||||
* 配置命令参数。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDescription('为开发环境生成平台 RSA 密钥、测试商户 RSA 密钥,并写回商户公钥。')
|
||||
->addOption('merchant-id', null, InputOption::VALUE_OPTIONAL, '指定商户 ID')
|
||||
->addOption('merchant-no', null, InputOption::VALUE_OPTIONAL, '指定商户号')
|
||||
->addOption('force-platform', null, InputOption::VALUE_NONE, '强制覆盖已存在的平台密钥文件')
|
||||
->addOption('force-merchant', null, InputOption::VALUE_NONE, '强制覆盖已存在的商户密钥文件和商户 RSA 公钥');
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行初始化。
|
||||
*
|
||||
* @param InputInterface $input 命令输入
|
||||
* @param OutputInterface $output 输出对象
|
||||
* @return int 命令退出码
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
try {
|
||||
$merchant = $this->pickMerchant(
|
||||
$this->optionInt($input, 'merchant-id', 0),
|
||||
trim($this->optionString($input, 'merchant-no', ''))
|
||||
);
|
||||
$directory = base_path(false) . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'epay';
|
||||
$forcePlatform = $this->optionBool($input, 'force-platform', false);
|
||||
$forceMerchant = $this->optionBool($input, 'force-merchant', false);
|
||||
|
||||
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {
|
||||
throw new CommandException('创建密钥目录失败: ' . $directory);
|
||||
}
|
||||
|
||||
$platformPaths = $this->bootstrapPlatformKeys($directory, $forcePlatform);
|
||||
$merchantPaths = $this->bootstrapMerchantKeys($merchant, $directory, $forceMerchant);
|
||||
|
||||
$output->writeln('<info>[完成]</info> ePay V2 开发联调密钥已就绪');
|
||||
$output->writeln(sprintf(
|
||||
'商户: id=%d no=%s name=%s',
|
||||
(int) $merchant->id,
|
||||
(string) $merchant->merchant_no,
|
||||
(string) $merchant->merchant_name
|
||||
));
|
||||
$output->writeln('平台私钥: ' . $platformPaths['private']);
|
||||
$output->writeln('平台公钥: ' . $platformPaths['public']);
|
||||
$output->writeln('商户私钥: ' . $merchantPaths['private']);
|
||||
$output->writeln('商户公钥: ' . $merchantPaths['public']);
|
||||
$output->writeln('商户公钥预览: ' . FormatHelper::maskCredentialValue($merchantPaths['public_key'], false));
|
||||
$output->writeln('下一步可直接运行: php webman epay:v2-api --live --merchant-id=' . (int) $merchant->id);
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>[失败]</error> ' . $this->formatThrowable($e));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成或复用平台 RSA 密钥。
|
||||
*
|
||||
* @param string $directory 密钥目录
|
||||
* @param bool $force 是否强制覆盖
|
||||
* @return array{private: string, public: string}
|
||||
* @throws CommandException
|
||||
*/
|
||||
private function bootstrapPlatformKeys(string $directory, bool $force): array
|
||||
{
|
||||
$privatePath = $directory . DIRECTORY_SEPARATOR . 'platform-private.pem';
|
||||
$publicPath = $directory . DIRECTORY_SEPARATOR . 'platform-public.pem';
|
||||
|
||||
if (!$force && is_file($privatePath) && is_file($publicPath)) {
|
||||
return ['private' => $privatePath, 'public' => $publicPath];
|
||||
}
|
||||
|
||||
$pair = $this->generateKeyPair();
|
||||
$this->writePemFile($privatePath, $pair['private_key']);
|
||||
$this->writePemFile($publicPath, $pair['public_key']);
|
||||
|
||||
return ['private' => $privatePath, 'public' => $publicPath];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成或复用商户 RSA 密钥,并写回商户公钥。
|
||||
*
|
||||
* @param Merchant $merchant 商户
|
||||
* @param string $directory 密钥目录
|
||||
* @param bool $force 是否强制覆盖
|
||||
* @return array{private: string, public: string, public_key: string}
|
||||
* @throws CommandException
|
||||
*/
|
||||
private function bootstrapMerchantKeys(Merchant $merchant, string $directory, bool $force): array
|
||||
{
|
||||
/** @var MerchantApiCredentialRepository $credentialRepository */
|
||||
$credentialRepository = $this->resolve(MerchantApiCredentialRepository::class);
|
||||
$credential = $credentialRepository->findByMerchantId((int) $merchant->id);
|
||||
$privatePath = $directory . DIRECTORY_SEPARATOR . sprintf('merchant-%d-private.pem', (int) $merchant->id);
|
||||
$publicPath = $directory . DIRECTORY_SEPARATOR . sprintf('merchant-%d-public.pem', (int) $merchant->id);
|
||||
|
||||
if (!$force && is_file($privatePath) && is_file($publicPath) && trim((string) ($credential?->merchant_public_key ?? '')) !== '') {
|
||||
return [
|
||||
'private' => $privatePath,
|
||||
'public' => $publicPath,
|
||||
'public_key' => trim((string) ($credential?->merchant_public_key ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
$pair = $this->generateKeyPair();
|
||||
$this->writePemFile($privatePath, $pair['private_key']);
|
||||
$this->writePemFile($publicPath, $pair['public_key']);
|
||||
|
||||
$credentialRepository->updateOrCreate(
|
||||
['merchant_id' => (int) $merchant->id],
|
||||
[
|
||||
'merchant_id' => (int) $merchant->id,
|
||||
'status' => AuthConstant::CREDENTIAL_STATUS_ENABLED,
|
||||
'sign_type' => (int) ($credential?->sign_type ?? AuthConstant::API_SIGN_TYPE_MD5),
|
||||
'api_key' => (string) ($credential?->api_key ?: bin2hex(random_bytes(16))),
|
||||
'merchant_public_key' => $pair['public_key'],
|
||||
]
|
||||
);
|
||||
|
||||
return [
|
||||
'private' => $privatePath,
|
||||
'public' => $publicPath,
|
||||
'public_key' => $pair['public_key'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择目标商户。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param string $merchantNo 商户号
|
||||
* @return Merchant 商户
|
||||
* @throws CommandException
|
||||
*/
|
||||
private function pickMerchant(int $merchantId, string $merchantNo): Merchant
|
||||
{
|
||||
/** @var MerchantRepository $merchantRepository */
|
||||
$merchantRepository = $this->resolve(MerchantRepository::class);
|
||||
|
||||
if ($merchantId > 0) {
|
||||
$merchant = $merchantRepository->find($merchantId);
|
||||
if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new CommandException('指定商户不存在或未启用: ' . $merchantId);
|
||||
}
|
||||
|
||||
return $merchant;
|
||||
}
|
||||
|
||||
if ($merchantNo !== '') {
|
||||
$merchant = $merchantRepository->findByMerchantNo($merchantNo);
|
||||
if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new CommandException('指定商户不存在或未启用: ' . $merchantNo);
|
||||
}
|
||||
|
||||
return $merchant;
|
||||
}
|
||||
|
||||
$merchant = $merchantRepository->enabledList(['id', 'merchant_no', 'merchant_name', 'status'])->first();
|
||||
if (!$merchant) {
|
||||
throw new CommandException('未找到启用中的商户。');
|
||||
}
|
||||
|
||||
return $merchant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 RSA 密钥对。
|
||||
*
|
||||
* @return array{private_key: string, public_key: string}
|
||||
* @throws CommandException
|
||||
*/
|
||||
private function generateKeyPair(): array
|
||||
{
|
||||
$this->ensureOpenSslConfig();
|
||||
$pair = $this->generateKeyPairWithOpenSsl();
|
||||
if ($pair !== null) {
|
||||
return $pair;
|
||||
}
|
||||
|
||||
$pair = $this->generateKeyPairWithSshKeygen();
|
||||
if ($pair !== null) {
|
||||
return $pair;
|
||||
}
|
||||
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
$pair = $this->generateKeyPairWithPowerShell();
|
||||
if ($pair !== null) {
|
||||
return $pair;
|
||||
}
|
||||
}
|
||||
|
||||
throw new CommandException('生成 RSA 密钥对失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试通过 OpenSSL 扩展生成密钥对。
|
||||
*
|
||||
* @return array{private_key: string, public_key: string}|null
|
||||
*/
|
||||
private function generateKeyPairWithOpenSsl(): ?array
|
||||
{
|
||||
while (openssl_error_string()) {
|
||||
}
|
||||
|
||||
$resource = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
if ($resource === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$privateKey = '';
|
||||
if (!openssl_pkey_export($resource, $privateKey) || trim($privateKey) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
$publicKey = trim((string) ($details['key'] ?? ''));
|
||||
if ($publicKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'private_key' => trim($privateKey),
|
||||
'public_key' => $publicKey,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过系统自带 ssh-keygen 生成 PEM 私钥和 PKCS8 公钥。
|
||||
*
|
||||
* @return array{private_key: string, public_key: string}|null
|
||||
*/
|
||||
private function generateKeyPairWithSshKeygen(): ?array
|
||||
{
|
||||
$runtimeDir = base_path(false) . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'epay';
|
||||
if (!is_dir($runtimeDir) && !mkdir($runtimeDir, 0777, true) && !is_dir($runtimeDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$basePath = $runtimeDir . DIRECTORY_SEPARATOR . 'sshkeygen-' . bin2hex(random_bytes(6));
|
||||
$privatePath = str_replace('\\', '/', $basePath);
|
||||
$publicPath = $privatePath . '.pub';
|
||||
|
||||
try {
|
||||
$generateCommand = sprintf(
|
||||
'ssh-keygen -q -t rsa -b 2048 -m PEM -N "" -f %s 2>&1',
|
||||
escapeshellarg($privatePath)
|
||||
);
|
||||
shell_exec($generateCommand);
|
||||
|
||||
if (!is_file($basePath) || !is_file($basePath . '.pub')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$privateKey = trim((string) file_get_contents($basePath));
|
||||
$exportCommand = sprintf(
|
||||
'ssh-keygen -e -m PKCS8 -f %s 2>&1',
|
||||
escapeshellarg($publicPath)
|
||||
);
|
||||
$publicKey = trim((string) shell_exec($exportCommand));
|
||||
if ($privateKey === '' || $publicKey === '' || !str_contains($publicKey, 'BEGIN PUBLIC KEY')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'private_key' => $privateKey,
|
||||
'public_key' => $publicKey,
|
||||
];
|
||||
} finally {
|
||||
@unlink($basePath);
|
||||
@unlink($basePath . '.pub');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Windows 下通过 PowerShell/.NET 生成密钥对。
|
||||
*
|
||||
* @return array{private_key: string, public_key: string}|null
|
||||
*/
|
||||
private function generateKeyPairWithPowerShell(): ?array
|
||||
{
|
||||
$script = <<<'POWERSHELL'
|
||||
$rsa = [System.Security.Cryptography.RSA]::Create(2048)
|
||||
function Convert-ToPem([string]$Label, [byte[]]$Bytes) {
|
||||
$base64 = [System.Convert]::ToBase64String($Bytes)
|
||||
$lines = [System.Text.RegularExpressions.Regex]::Matches($base64, '.{1,64}') | ForEach-Object { $_.Value }
|
||||
return "-----BEGIN $Label-----`n$($lines -join "`n")`n-----END $Label-----"
|
||||
}
|
||||
$result = [ordered]@{
|
||||
private_key = Convert-ToPem 'PRIVATE KEY' ($rsa.ExportPkcs8PrivateKey())
|
||||
public_key = Convert-ToPem 'PUBLIC KEY' ($rsa.ExportSubjectPublicKeyInfo())
|
||||
}
|
||||
$result | ConvertTo-Json -Compress
|
||||
POWERSHELL;
|
||||
|
||||
$encodedScript = iconv('UTF-8', 'UTF-16LE', $script);
|
||||
if ($encodedScript === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$command = 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ' . base64_encode($encodedScript) . ' 2>&1';
|
||||
$output = shell_exec($command);
|
||||
if (!is_string($output) || trim($output) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode(trim($output), true);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$privateKey = trim((string) ($decoded['private_key'] ?? ''));
|
||||
$publicKey = trim((string) ($decoded['public_key'] ?? ''));
|
||||
if ($privateKey === '' || $publicKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'private_key' => $privateKey,
|
||||
'public_key' => $publicKey,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 openssl_pkey_new() 补齐配置文件路径。
|
||||
*
|
||||
* 某些 Windows PHP 环境默认把 openssl.cnf 指向不存在的位置,这里优先回退到
|
||||
* 当前 PHP 目录自带的 extras/ssl/openssl.cnf。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function ensureOpenSslConfig(): void
|
||||
{
|
||||
$current = trim((string) getenv('OPENSSL_CONF'));
|
||||
if ($current !== '' && is_file($current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
$loadedIni = php_ini_loaded_file();
|
||||
if (is_string($loadedIni) && $loadedIni !== '') {
|
||||
$candidates[] = dirname($loadedIni) . DIRECTORY_SEPARATOR . 'extras' . DIRECTORY_SEPARATOR . 'ssl' . DIRECTORY_SEPARATOR . 'openssl.cnf';
|
||||
}
|
||||
if (defined('PHP_BINARY')) {
|
||||
$candidates[] = dirname(PHP_BINARY) . DIRECTORY_SEPARATOR . 'extras' . DIRECTORY_SEPARATOR . 'ssl' . DIRECTORY_SEPARATOR . 'openssl.cnf';
|
||||
}
|
||||
$locations = openssl_get_cert_locations();
|
||||
if (!empty($locations['default_cert_file'])) {
|
||||
$candidates[] = (string) $locations['default_cert_file'];
|
||||
}
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_string($candidate) && $candidate !== '' && is_file($candidate)) {
|
||||
putenv('OPENSSL_CONF=' . $candidate);
|
||||
$_ENV['OPENSSL_CONF'] = $candidate;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 PEM 文件。
|
||||
*
|
||||
* @param string $path 文件路径
|
||||
* @param string $content 文件内容
|
||||
* @return void
|
||||
* @throws CommandException
|
||||
*/
|
||||
private function writePemFile(string $path, string $content): void
|
||||
{
|
||||
if (file_put_contents($path, $content . PHP_EOL) === false) {
|
||||
throw new CommandException('写入密钥文件失败: ' . $path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化异常信息。
|
||||
*
|
||||
* @param Throwable $e 异常
|
||||
* @return string 文本信息
|
||||
*/
|
||||
private function formatThrowable(\Throwable $e): string
|
||||
{
|
||||
return $e::class . ':' . $e->getMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取字符串选项。
|
||||
*
|
||||
* @param InputInterface $input 命令输入
|
||||
* @param string $name 选项名称
|
||||
* @param string $default 默认值
|
||||
* @return string 选项值
|
||||
*/
|
||||
private function optionString(InputInterface $input, string $name, string $default = ''): string
|
||||
{
|
||||
$value = $input->getOption($name);
|
||||
return $value === null || $value === false ? $default : (is_string($value) ? $value : (string) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取整数选项。
|
||||
*
|
||||
* @param InputInterface $input 命令输入
|
||||
* @param string $name 选项名称
|
||||
* @param int $default 默认值
|
||||
* @return int 选项值
|
||||
*/
|
||||
private function optionInt(InputInterface $input, string $name, int $default = 0): int
|
||||
{
|
||||
$value = $input->getOption($name);
|
||||
return is_numeric($value) ? (int) $value : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取布尔选项。
|
||||
*
|
||||
* @param InputInterface $input 命令输入
|
||||
* @param string $name 选项名称
|
||||
* @param bool $default 默认值
|
||||
* @return bool 布尔值
|
||||
*/
|
||||
private function optionBool(InputInterface $input, string $name, bool $default = false): bool
|
||||
{
|
||||
$value = $input->getOption($name);
|
||||
if ($value === null || $value === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$filtered = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
|
||||
|
||||
return $filtered === null ? $default : $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从容器解析实例。
|
||||
*
|
||||
* @param string $class 类名
|
||||
* @return object 实例
|
||||
* @throws CommandException
|
||||
*/
|
||||
private function resolve(string $class): object
|
||||
{
|
||||
try {
|
||||
$instance = container_make($class, []);
|
||||
} catch (\Throwable $e) {
|
||||
throw new CommandException("无法解析 {$class}。", 0, $e);
|
||||
}
|
||||
|
||||
if (!is_object($instance)) {
|
||||
throw new CommandException("解析后的 {$class} 不是对象。");
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace app\command;
|
||||
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\exception\CommandException;
|
||||
use app\service\account\funds\MerchantAccountService;
|
||||
use app\service\merchant\MerchantService;
|
||||
use app\service\payment\order\PayOrderService;
|
||||
@@ -11,7 +12,6 @@ use app\service\payment\order\RefundService;
|
||||
use app\service\payment\settlement\SettlementService;
|
||||
use app\service\payment\trace\TradeTraceService;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -223,7 +223,7 @@ class MpayTest extends Command
|
||||
} elseif ($this->envBool('MPAY_TEST_REFUND_MARK_FAIL', false)) {
|
||||
$service->markRefundFailed((string) $refund->refund_no, [
|
||||
'failed_at' => $this->envString('MPAY_TEST_REFUND_FAILED_AT', FormatHelper::timestamp(time())),
|
||||
'last_error' => $this->envString('MPAY_TEST_REFUND_LAST_ERROR', 'mpay smoke refund failed'),
|
||||
'last_error' => $this->envString('MPAY_TEST_REFUND_LAST_ERROR', '烟雾测试退款失败'),
|
||||
]);
|
||||
$message .= ', 已标记失败';
|
||||
} elseif ($this->envBool('MPAY_TEST_REFUND_MARK_SUCCESS', false)) {
|
||||
@@ -437,18 +437,18 @@ class MpayTest extends Command
|
||||
*
|
||||
* @param string $class 类名
|
||||
* @return object 对象实例
|
||||
* @throws RuntimeException
|
||||
* @throws CommandException
|
||||
*/
|
||||
private function resolve(string $class): object
|
||||
{
|
||||
try {
|
||||
$instance = container_make($class, []);
|
||||
} catch (\Throwable $e) {
|
||||
throw new RuntimeException("无法解析 {$class}: " . $e->getMessage(), 0, $e);
|
||||
throw new CommandException("无法解析 {$class}。", 0, $e);
|
||||
}
|
||||
|
||||
if (!is_object($instance)) {
|
||||
throw new RuntimeException("解析后的 {$class} 不是对象。");
|
||||
throw new CommandException("解析后的 {$class} 不是对象。");
|
||||
}
|
||||
|
||||
return $instance;
|
||||
@@ -460,12 +460,12 @@ class MpayTest extends Command
|
||||
* @param object $instance 实例
|
||||
* @param string $method 方法名
|
||||
* @return void
|
||||
* @throws RuntimeException
|
||||
* @throws CommandException
|
||||
*/
|
||||
private function ensureMethod(object $instance, string $method): void
|
||||
{
|
||||
if (!method_exists($instance, $method)) {
|
||||
throw new RuntimeException(sprintf('未找到方法 %s::%s。', $instance::class, $method));
|
||||
throw new CommandException(sprintf('未找到方法 %s::%s。', $instance::class, $method));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,8 +555,7 @@ class MpayTest extends Command
|
||||
{
|
||||
$data = method_exists($e, 'getData') ? $e->getData() : [];
|
||||
$suffix = $data ? ' ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : '';
|
||||
|
||||
return $e::class . ': ' . $e->getMessage() . $suffix;
|
||||
return $e::class . ':' . $e->getMessage() . $suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
52
app/command/NotifyRetry.php
Normal file
52
app/command/NotifyRetry.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\service\payment\runtime\MerchantNotifyDispatcherService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* 商户通知重试命令。
|
||||
*
|
||||
* 用于手动或定时重试已经到达下次重试时间的商户通知任务。
|
||||
*/
|
||||
#[AsCommand('payment:notify-retry', '重试到期的商户通知任务')]
|
||||
class NotifyRetry extends Command
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantNotifyDispatcherService $merchantNotifyDispatcherService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置命令参数。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('limit', null, InputOption::VALUE_OPTIONAL, '单次最多处理多少条任务', 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行重试。
|
||||
*
|
||||
* @param InputInterface $input 输入
|
||||
* @param OutputInterface $output 输出
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$limit = max(1, (int) $input->getOption('limit'));
|
||||
$count = $this->merchantNotifyDispatcherService->dispatchRetryableTasks($limit);
|
||||
|
||||
$output->writeln(sprintf('<info>已处理 %d 条商户通知任务</info>', $count));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ namespace app\command;
|
||||
use app\repository\system\config\SystemConfigRepository;
|
||||
use app\service\system\config\SystemConfigDefinitionService;
|
||||
use app\service\system\config\SystemConfigRuntimeService;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -57,7 +56,7 @@ class SystemConfigSync extends Command
|
||||
}
|
||||
|
||||
$configKey = strtolower(trim((string) ($rule['field'] ?? '')));
|
||||
if ($configKey === '') {
|
||||
if ($configKey === '' || str_starts_with($configKey, '__')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -86,5 +85,3 @@ class SystemConfigSync extends Command
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ use support\Log;
|
||||
*
|
||||
* 生命周期:
|
||||
* - 服务层会在每次动作前调用 `init($channelConfig)` 注入该通道配置。
|
||||
* - 子类可在 `init()` 中配置第三方 SDK(例如 yansongda/pay)或读取必填参数。
|
||||
* - 子类可在 `init()` 中配置第三方 SDK 或读取必填参数。
|
||||
*
|
||||
* 约定:
|
||||
* - 这里的 `$channelConfig` 来源通常是 `ma_payment_plugin_conf.config`,并附带通道维度上下文。
|
||||
@@ -191,7 +191,11 @@ abstract class BasePayment implements PayPluginInterface
|
||||
return $this->httpClient->request($method, $url, $options);
|
||||
} catch (GuzzleException $e) {
|
||||
Log::error(sprintf('[BasePayment] HTTP 请求失败: %s %s, error=%s', $method, $url, $e->getMessage()));
|
||||
throw new PaymentException('渠道请求失败:' . $e->getMessage(), 402, ['method' => $method, 'url' => $url]);
|
||||
throw new PaymentException('渠道请求失败', 402, [
|
||||
'method' => $method,
|
||||
'url' => $url,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,50 +5,70 @@ namespace app\common\constant;
|
||||
/**
|
||||
* 认证相关常量。
|
||||
*
|
||||
* 统一管理登录域、令牌状态、签名类型等枚举值。
|
||||
* 统一管理登录域、会话状态、接口凭证状态和开放接口签名类型。
|
||||
*/
|
||||
final class AuthConstant
|
||||
{
|
||||
/**
|
||||
* 管理员登录域。
|
||||
*/
|
||||
public const GUARD_ADMIN = 1;
|
||||
public const GUARD_ADMIN = 'admin';
|
||||
|
||||
/**
|
||||
* 商户登录域。
|
||||
*/
|
||||
public const GUARD_MERCHANT = 2;
|
||||
public const GUARD_MERCHANT = 'merchant';
|
||||
|
||||
/**
|
||||
* JWT 签名算法。
|
||||
*/
|
||||
public const JWT_ALG_HS256 = 'HS256';
|
||||
public const JWT_ALGORITHM_HS256 = 'HS256';
|
||||
|
||||
/**
|
||||
* 令牌禁用状态。
|
||||
* 会话令牌禁用状态。
|
||||
*/
|
||||
public const TOKEN_STATUS_DISABLED = 0;
|
||||
|
||||
/**
|
||||
* 令牌启用状态。
|
||||
* 会话令牌启用状态。
|
||||
*/
|
||||
public const TOKEN_STATUS_ENABLED = 1;
|
||||
|
||||
/**
|
||||
* 登录禁用状态。
|
||||
* 接口凭证禁用状态。
|
||||
*/
|
||||
public const LOGIN_STATUS_DISABLED = 0;
|
||||
public const CREDENTIAL_STATUS_DISABLED = 0;
|
||||
|
||||
/**
|
||||
* 登录启用状态。
|
||||
* 接口凭证启用状态。
|
||||
*/
|
||||
public const LOGIN_STATUS_ENABLED = 1;
|
||||
public const CREDENTIAL_STATUS_ENABLED = 1;
|
||||
|
||||
/**
|
||||
* API 签名类型:MD5。
|
||||
*/
|
||||
public const API_SIGN_TYPE_MD5 = 0;
|
||||
|
||||
/**
|
||||
* API 签名类型:SHA256WithRSA。
|
||||
*/
|
||||
public const API_SIGN_TYPE_SHA256_WITH_RSA = 1;
|
||||
|
||||
/**
|
||||
* API 签名类型名称:MD5。
|
||||
*/
|
||||
public const API_SIGN_NAME_MD5 = 'MD5';
|
||||
|
||||
/**
|
||||
* API 签名类型名称:SHA256WithRSA。
|
||||
*/
|
||||
public const API_SIGN_NAME_SHA256_WITH_RSA = 'SHA256WithRSA';
|
||||
|
||||
/**
|
||||
* API 签名类型归一化名称:SHA256WITHRSA。
|
||||
*/
|
||||
public const API_SIGN_NORMALIZED_SHA256_WITH_RSA = 'SHA256WITHRSA';
|
||||
|
||||
/**
|
||||
* 获取签名类型映射。
|
||||
*
|
||||
@@ -57,24 +77,36 @@ final class AuthConstant
|
||||
public static function signTypeMap(): array
|
||||
{
|
||||
return [
|
||||
self::API_SIGN_TYPE_MD5 => 'MD5',
|
||||
self::API_SIGN_TYPE_MD5 => self::API_SIGN_NAME_MD5,
|
||||
self::API_SIGN_TYPE_SHA256_WITH_RSA => self::API_SIGN_NAME_SHA256_WITH_RSA,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接口凭证状态映射。
|
||||
*
|
||||
* @return array<int, string> 接口凭证状态名称表
|
||||
*/
|
||||
public static function credentialStatusMap(): array
|
||||
{
|
||||
return [
|
||||
self::CREDENTIAL_STATUS_ENABLED => '启用',
|
||||
self::CREDENTIAL_STATUS_DISABLED => '禁用',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录域映射。
|
||||
*
|
||||
* @return array<int, string> 登录域名称表
|
||||
* @return array<string, string> 登录域名称表
|
||||
*/
|
||||
public static function guardMap(): array
|
||||
{
|
||||
return [
|
||||
self::GUARD_ADMIN => 'admin',
|
||||
self::GUARD_MERCHANT => 'merchant',
|
||||
self::GUARD_ADMIN => '管理后台',
|
||||
self::GUARD_MERCHANT => '商户后台',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -18,12 +18,12 @@ final class CommonConstant
|
||||
public const STATUS_ENABLED = 1;
|
||||
|
||||
/**
|
||||
* 否。
|
||||
* 否,通常用于布尔类字段的数值表示。
|
||||
*/
|
||||
public const NO = 0;
|
||||
|
||||
/**
|
||||
* 是。
|
||||
* 是,通常用于布尔类字段的数值表示。
|
||||
*/
|
||||
public const YES = 1;
|
||||
|
||||
@@ -56,4 +56,3 @@ final class CommonConstant
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
67
app/common/constant/EventConstant.php
Normal file
67
app/common/constant/EventConstant.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\constant;
|
||||
|
||||
/**
|
||||
* 事件名称常量。
|
||||
*
|
||||
* 本类维护已定义的领域事件;事件是否实际启用处理器,以 config/event.php 注册为准。
|
||||
* 生命周期服务可以先 dispatch 关键节点事件,后续需要告警、风控、统计或补偿时再注册 listener。
|
||||
*/
|
||||
final class EventConstant
|
||||
{
|
||||
/**
|
||||
* 系统配置已变更。
|
||||
*/
|
||||
public const SYSTEM_CONFIG_CHANGED = 'system.config.changed';
|
||||
|
||||
/**
|
||||
* 支付单首次进入成功态。
|
||||
*/
|
||||
public const PAYMENT_PAY_ORDER_SUCCEEDED = 'payment.pay_order.succeeded';
|
||||
|
||||
/**
|
||||
* 支付单进入失败态。
|
||||
*/
|
||||
public const PAYMENT_PAY_ORDER_FAILED = 'payment.pay_order.failed';
|
||||
|
||||
/**
|
||||
* 支付单进入关闭态。
|
||||
*/
|
||||
public const PAYMENT_PAY_ORDER_CLOSED = 'payment.pay_order.closed';
|
||||
|
||||
/**
|
||||
* 支付单进入超时态。
|
||||
*/
|
||||
public const PAYMENT_PAY_ORDER_TIMEOUT = 'payment.pay_order.timeout';
|
||||
|
||||
/**
|
||||
* 退款单进入成功态。
|
||||
*/
|
||||
public const REFUND_ORDER_SUCCEEDED = 'payment.refund_order.succeeded';
|
||||
|
||||
/**
|
||||
* 退款单进入失败态。
|
||||
*/
|
||||
public const REFUND_ORDER_FAILED = 'payment.refund_order.failed';
|
||||
|
||||
/**
|
||||
* 清算单进入成功态。
|
||||
*/
|
||||
public const SETTLEMENT_ORDER_SUCCEEDED = 'payment.settlement_order.succeeded';
|
||||
|
||||
/**
|
||||
* 清算单进入失败态。
|
||||
*/
|
||||
public const SETTLEMENT_ORDER_FAILED = 'payment.settlement_order.failed';
|
||||
|
||||
/**
|
||||
* 商户通知派发成功。
|
||||
*/
|
||||
public const MERCHANT_NOTIFY_SUCCEEDED = 'payment.merchant_notify.succeeded';
|
||||
|
||||
/**
|
||||
* 商户通知派发失败。
|
||||
*/
|
||||
public const MERCHANT_NOTIFY_FAILED = 'payment.merchant_notify.failed';
|
||||
}
|
||||
@@ -69,23 +69,77 @@ final class FileConstant
|
||||
*/
|
||||
public const STORAGE_REMOTE_URL = 4;
|
||||
|
||||
/**
|
||||
* 文件存储默认引擎配置 key。
|
||||
*/
|
||||
public const CONFIG_DEFAULT_ENGINE = 'file_storage_default_engine';
|
||||
/**
|
||||
* 本地公开目录访问地址配置 key。
|
||||
*/
|
||||
public const CONFIG_LOCAL_PUBLIC_BASE_URL = 'file_storage_local_public_base_url';
|
||||
/**
|
||||
* 本地公开目录路径配置 key。
|
||||
*/
|
||||
public const CONFIG_LOCAL_PUBLIC_DIR = 'file_storage_local_public_dir';
|
||||
/**
|
||||
* 本地私有目录路径配置 key。
|
||||
*/
|
||||
public const CONFIG_LOCAL_PRIVATE_DIR = 'file_storage_local_private_dir';
|
||||
/**
|
||||
* 上传文件大小上限配置 key,单位 MB。
|
||||
*/
|
||||
public const CONFIG_UPLOAD_MAX_SIZE_MB = 'file_storage_upload_max_size_mb';
|
||||
/**
|
||||
* 远程下载大小上限配置 key,单位 MB。
|
||||
*/
|
||||
public const CONFIG_REMOTE_DOWNLOAD_LIMIT_MB = 'file_storage_remote_download_limit_mb';
|
||||
/**
|
||||
* 允许上传扩展名配置 key。
|
||||
*/
|
||||
public const CONFIG_ALLOWED_EXTENSIONS = 'file_storage_allowed_extensions';
|
||||
/**
|
||||
* 阿里云 OSS Endpoint 配置 key。
|
||||
*/
|
||||
public const CONFIG_OSS_ENDPOINT = 'file_storage_aliyun_oss_endpoint';
|
||||
/**
|
||||
* 阿里云 OSS Bucket 配置 key。
|
||||
*/
|
||||
public const CONFIG_OSS_BUCKET = 'file_storage_aliyun_oss_bucket';
|
||||
/**
|
||||
* 阿里云 OSS Access Key ID 配置 key。
|
||||
*/
|
||||
public const CONFIG_OSS_ACCESS_KEY_ID = 'file_storage_aliyun_oss_access_key_id';
|
||||
/**
|
||||
* 阿里云 OSS Access Key Secret 配置 key。
|
||||
*/
|
||||
public const CONFIG_OSS_ACCESS_KEY_SECRET = 'file_storage_aliyun_oss_access_key_secret';
|
||||
/**
|
||||
* 阿里云 OSS 公开域名配置 key。
|
||||
*/
|
||||
public const CONFIG_OSS_PUBLIC_DOMAIN = 'file_storage_aliyun_oss_public_domain';
|
||||
/**
|
||||
* 阿里云 OSS 地域配置 key。
|
||||
*/
|
||||
public const CONFIG_OSS_REGION = 'file_storage_aliyun_oss_region';
|
||||
/**
|
||||
* 腾讯云 COS 地域配置 key。
|
||||
*/
|
||||
public const CONFIG_COS_REGION = 'file_storage_tencent_cos_region';
|
||||
/**
|
||||
* 腾讯云 COS Bucket 配置 key。
|
||||
*/
|
||||
public const CONFIG_COS_BUCKET = 'file_storage_tencent_cos_bucket';
|
||||
/**
|
||||
* 腾讯云 COS SecretId 配置 key。
|
||||
*/
|
||||
public const CONFIG_COS_SECRET_ID = 'file_storage_tencent_cos_secret_id';
|
||||
/**
|
||||
* 腾讯云 COS SecretKey 配置 key。
|
||||
*/
|
||||
public const CONFIG_COS_SECRET_KEY = 'file_storage_tencent_cos_secret_key';
|
||||
/**
|
||||
* 腾讯云 COS 公开域名配置 key。
|
||||
*/
|
||||
public const CONFIG_COS_PUBLIC_DOMAIN = 'file_storage_tencent_cos_public_domain';
|
||||
|
||||
/**
|
||||
@@ -227,4 +281,3 @@ final class FileConstant
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,19 +7,67 @@ namespace app\common\constant;
|
||||
*/
|
||||
final class LedgerConstant
|
||||
{
|
||||
/**
|
||||
* 支付冻结流水。
|
||||
*/
|
||||
public const BIZ_TYPE_PAY_FREEZE = 0;
|
||||
/**
|
||||
* 支付扣费流水。
|
||||
*/
|
||||
public const BIZ_TYPE_PAY_DEDUCT = 1;
|
||||
/**
|
||||
* 支付释放流水。
|
||||
*/
|
||||
public const BIZ_TYPE_PAY_RELEASE = 2;
|
||||
/**
|
||||
* 清算入账流水。
|
||||
*/
|
||||
public const BIZ_TYPE_SETTLEMENT_CREDIT = 3;
|
||||
/**
|
||||
* 退款冲正流水。
|
||||
*/
|
||||
public const BIZ_TYPE_REFUND_REVERSE = 4;
|
||||
/**
|
||||
* 人工调整流水。
|
||||
*/
|
||||
public const BIZ_TYPE_MANUAL_ADJUST = 5;
|
||||
/**
|
||||
* 转账扣款流水。
|
||||
*/
|
||||
public const BIZ_TYPE_TRANSFER_DEDUCT = 6;
|
||||
/**
|
||||
* 转账手续费流水。
|
||||
*/
|
||||
public const BIZ_TYPE_TRANSFER_FEE = 7;
|
||||
/**
|
||||
* 转账释放流水。
|
||||
*/
|
||||
public const BIZ_TYPE_TRANSFER_RELEASE = 8;
|
||||
|
||||
/**
|
||||
* 账务事件的创建动作。
|
||||
*/
|
||||
public const EVENT_TYPE_CREATE = 0;
|
||||
/**
|
||||
* 账务事件的成功动作。
|
||||
*/
|
||||
public const EVENT_TYPE_SUCCESS = 1;
|
||||
/**
|
||||
* 账务事件的失败动作。
|
||||
*/
|
||||
public const EVENT_TYPE_FAILED = 2;
|
||||
/**
|
||||
* 账务事件的冲正动作。
|
||||
*/
|
||||
public const EVENT_TYPE_REVERSE = 3;
|
||||
|
||||
/**
|
||||
* 流水入账方向。
|
||||
*/
|
||||
public const DIRECTION_IN = 0;
|
||||
/**
|
||||
* 流水出账方向。
|
||||
*/
|
||||
public const DIRECTION_OUT = 1;
|
||||
|
||||
/**
|
||||
@@ -36,6 +84,9 @@ final class LedgerConstant
|
||||
self::BIZ_TYPE_SETTLEMENT_CREDIT => '清算入账',
|
||||
self::BIZ_TYPE_REFUND_REVERSE => '退款冲正',
|
||||
self::BIZ_TYPE_MANUAL_ADJUST => '人工调整',
|
||||
self::BIZ_TYPE_TRANSFER_DEDUCT => '转账扣款',
|
||||
self::BIZ_TYPE_TRANSFER_FEE => '转账手续费',
|
||||
self::BIZ_TYPE_TRANSFER_RELEASE => '转账释放',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -69,5 +120,3 @@ final class LedgerConstant
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -68,4 +68,3 @@ final class MerchantConstant
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,22 +7,74 @@ namespace app\common\constant;
|
||||
*/
|
||||
final class NotifyConstant
|
||||
{
|
||||
/**
|
||||
* 商户通知事件:支付成功。
|
||||
*/
|
||||
public const EVENT_PAY_SUCCESS = 'PAY_SUCCESS';
|
||||
/**
|
||||
* 商户通知事件:退款成功。
|
||||
*/
|
||||
public const EVENT_REFUND_SUCCESS = 'REFUND_SUCCESS';
|
||||
/**
|
||||
* 商户通知事件:清算完成。
|
||||
*/
|
||||
public const EVENT_SETTLEMENT_SUCCESS = 'SETTLEMENT_SUCCESS';
|
||||
|
||||
/**
|
||||
* 异步通知类型。
|
||||
*/
|
||||
public const NOTIFY_TYPE_ASYNC = 0;
|
||||
/**
|
||||
* 查单通知类型。
|
||||
*/
|
||||
public const NOTIFY_TYPE_QUERY = 1;
|
||||
|
||||
/**
|
||||
* 异步回调类型。
|
||||
*/
|
||||
public const CALLBACK_TYPE_ASYNC = 0;
|
||||
/**
|
||||
* 同步回调类型。
|
||||
*/
|
||||
public const CALLBACK_TYPE_SYNC = 1;
|
||||
|
||||
/**
|
||||
* 验证状态:未知。
|
||||
*/
|
||||
public const VERIFY_STATUS_UNKNOWN = 0;
|
||||
/**
|
||||
* 验证状态:成功。
|
||||
*/
|
||||
public const VERIFY_STATUS_SUCCESS = 1;
|
||||
/**
|
||||
* 验证状态:失败。
|
||||
*/
|
||||
public const VERIFY_STATUS_FAILED = 2;
|
||||
|
||||
/**
|
||||
* 处理状态:待处理。
|
||||
*/
|
||||
public const PROCESS_STATUS_PENDING = 0;
|
||||
/**
|
||||
* 处理状态:成功。
|
||||
*/
|
||||
public const PROCESS_STATUS_SUCCESS = 1;
|
||||
/**
|
||||
* 处理状态:失败。
|
||||
*/
|
||||
public const PROCESS_STATUS_FAILED = 2;
|
||||
|
||||
/**
|
||||
* 任务状态:待通知。
|
||||
*/
|
||||
public const TASK_STATUS_PENDING = 0;
|
||||
/**
|
||||
* 任务状态:成功。
|
||||
*/
|
||||
public const TASK_STATUS_SUCCESS = 1;
|
||||
/**
|
||||
* 任务状态:失败。
|
||||
*/
|
||||
public const TASK_STATUS_FAILED = 2;
|
||||
|
||||
/**
|
||||
@@ -92,8 +144,20 @@ final class NotifyConstant
|
||||
self::TASK_STATUS_FAILED => '失败',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商户通知事件映射。
|
||||
*
|
||||
* @return array<string, string> 商户通知事件名称表
|
||||
*/
|
||||
public static function eventTypeMap(): array
|
||||
{
|
||||
return [
|
||||
self::EVENT_PAY_SUCCESS => '支付成功',
|
||||
self::EVENT_REFUND_SUCCESS => '退款成功',
|
||||
self::EVENT_SETTLEMENT_SUCCESS => '清算完成',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
98
app/common/constant/PaymentPluginStatusConstant.php
Normal file
98
app/common/constant/PaymentPluginStatusConstant.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\constant;
|
||||
|
||||
/**
|
||||
* 支付插件协议状态常量。
|
||||
*
|
||||
* 这些状态用于插件和平台运行时之间传递渠道结果,不等同于内部订单状态码。
|
||||
*/
|
||||
final class PaymentPluginStatusConstant
|
||||
{
|
||||
/**
|
||||
* 渠道支付成功。
|
||||
*/
|
||||
public const SUCCESS = 'success';
|
||||
|
||||
/**
|
||||
* 渠道支付失败。
|
||||
*/
|
||||
public const FAILED = 'failed';
|
||||
|
||||
/**
|
||||
* 渠道仍在处理中。
|
||||
*/
|
||||
public const PENDING = 'pending';
|
||||
|
||||
/**
|
||||
* 渠道订单已关闭。
|
||||
*/
|
||||
public const CLOSED = 'closed';
|
||||
|
||||
/**
|
||||
* 渠道状态未知。
|
||||
*/
|
||||
public const UNKNOWN = 'unknown';
|
||||
|
||||
/**
|
||||
* 插件回调允许返回的状态。
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function notifyStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::SUCCESS,
|
||||
self::FAILED,
|
||||
self::PENDING,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件查单成功状态别名。
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function successQueryAliases(): array
|
||||
{
|
||||
return [
|
||||
self::SUCCESS,
|
||||
'paid',
|
||||
'pay_success',
|
||||
'trade_success',
|
||||
'trade_finished',
|
||||
'finished',
|
||||
'successful',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件查单失败状态别名。
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function failedQueryAliases(): array
|
||||
{
|
||||
return [
|
||||
self::FAILED,
|
||||
'fail',
|
||||
'error',
|
||||
'pay_error',
|
||||
'trade_fail',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件查单关闭状态别名。
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function closedQueryAliases(): array
|
||||
{
|
||||
return [
|
||||
self::CLOSED,
|
||||
'close',
|
||||
'trade_closed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,12 @@ final class RouteConstant
|
||||
public const CHANNEL_TYPE_MERCHANT_SELF = 1;
|
||||
|
||||
/**
|
||||
* 代收通道模式。
|
||||
* 代收通道模式,资金直接进入平台侧。
|
||||
*/
|
||||
public const CHANNEL_MODE_COLLECT = 0;
|
||||
|
||||
/**
|
||||
* 自收通道模式。
|
||||
* 自收通道模式,资金直接进入商户侧。
|
||||
*/
|
||||
public const CHANNEL_MODE_SELF = 1;
|
||||
|
||||
@@ -87,4 +87,3 @@ final class RouteConstant
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,33 +7,105 @@ namespace app\common\constant;
|
||||
*/
|
||||
final class TradeConstant
|
||||
{
|
||||
/**
|
||||
* D0 清算周期。
|
||||
*/
|
||||
public const SETTLEMENT_CYCLE_D0 = 0;
|
||||
/**
|
||||
* D1 清算周期。
|
||||
*/
|
||||
public const SETTLEMENT_CYCLE_D1 = 1;
|
||||
/**
|
||||
* D7 清算周期。
|
||||
*/
|
||||
public const SETTLEMENT_CYCLE_D7 = 2;
|
||||
/**
|
||||
* T1 清算周期。
|
||||
*/
|
||||
public const SETTLEMENT_CYCLE_T1 = 3;
|
||||
/**
|
||||
* 其他清算周期。
|
||||
*/
|
||||
public const SETTLEMENT_CYCLE_OTHER = 4;
|
||||
|
||||
/**
|
||||
* 订单已创建,等待发起支付。
|
||||
*/
|
||||
public const ORDER_STATUS_CREATED = 0;
|
||||
/**
|
||||
* 订单支付中。
|
||||
*/
|
||||
public const ORDER_STATUS_PAYING = 1;
|
||||
/**
|
||||
* 订单支付成功。
|
||||
*/
|
||||
public const ORDER_STATUS_SUCCESS = 2;
|
||||
/**
|
||||
* 订单支付失败。
|
||||
*/
|
||||
public const ORDER_STATUS_FAILED = 3;
|
||||
/**
|
||||
* 订单已关闭。
|
||||
*/
|
||||
public const ORDER_STATUS_CLOSED = 4;
|
||||
/**
|
||||
* 订单已超时。
|
||||
*/
|
||||
public const ORDER_STATUS_TIMEOUT = 5;
|
||||
|
||||
/**
|
||||
* 手续费未处理。
|
||||
*/
|
||||
public const FEE_STATUS_NONE = 0;
|
||||
/**
|
||||
* 手续费已冻结。
|
||||
*/
|
||||
public const FEE_STATUS_FROZEN = 1;
|
||||
/**
|
||||
* 手续费已扣除。
|
||||
*/
|
||||
public const FEE_STATUS_DEDUCTED = 2;
|
||||
/**
|
||||
* 手续费已释放。
|
||||
*/
|
||||
public const FEE_STATUS_RELEASED = 3;
|
||||
|
||||
/**
|
||||
* 清算状态为空。
|
||||
*/
|
||||
public const SETTLEMENT_STATUS_NONE = 0;
|
||||
/**
|
||||
* 清算待处理。
|
||||
*/
|
||||
public const SETTLEMENT_STATUS_PENDING = 1;
|
||||
/**
|
||||
* 清算已完成。
|
||||
*/
|
||||
public const SETTLEMENT_STATUS_SETTLED = 2;
|
||||
/**
|
||||
* 清算已冲正。
|
||||
*/
|
||||
public const SETTLEMENT_STATUS_REVERSED = 3;
|
||||
|
||||
/**
|
||||
* 退款单已创建。
|
||||
*/
|
||||
public const REFUND_STATUS_CREATED = 0;
|
||||
/**
|
||||
* 退款单处理中。
|
||||
*/
|
||||
public const REFUND_STATUS_PROCESSING = 1;
|
||||
/**
|
||||
* 退款单成功。
|
||||
*/
|
||||
public const REFUND_STATUS_SUCCESS = 2;
|
||||
/**
|
||||
* 退款单失败。
|
||||
*/
|
||||
public const REFUND_STATUS_FAILED = 3;
|
||||
/**
|
||||
* 退款单已关闭。
|
||||
*/
|
||||
public const REFUND_STATUS_CLOSED = 4;
|
||||
|
||||
/**
|
||||
@@ -231,4 +303,3 @@ final class TradeConstant
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
36
app/common/constant/TransferConstant.php
Normal file
36
app/common/constant/TransferConstant.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\constant;
|
||||
|
||||
/**
|
||||
* 转账状态枚举。
|
||||
*/
|
||||
final class TransferConstant
|
||||
{
|
||||
/**
|
||||
* 转账待处理状态。
|
||||
*/
|
||||
public const TRANSFER_STATUS_PENDING = 0;
|
||||
/**
|
||||
* 转账成功状态。
|
||||
*/
|
||||
public const TRANSFER_STATUS_SUCCESS = 1;
|
||||
/**
|
||||
* 转账失败状态。
|
||||
*/
|
||||
public const TRANSFER_STATUS_FAILED = 2;
|
||||
|
||||
/**
|
||||
* 获取转账状态映射。
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function transferStatusMap(): array
|
||||
{
|
||||
return [
|
||||
self::TRANSFER_STATUS_PENDING => '待处理',
|
||||
self::TRANSFER_STATUS_SUCCESS => '成功',
|
||||
self::TRANSFER_STATUS_FAILED => '失败',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,20 @@ interface PaymentInterface
|
||||
/**
|
||||
* 发起支付下单。
|
||||
*
|
||||
* 插件必须返回系统标准结构,服务层会严格校验后写入支付单 `ext_json.presentation`:
|
||||
* - `pay_product`:支付产品或上游支付方式,例如 `alipay`、`wxpay`、`alipay_h5`
|
||||
* - `pay_action`:支付动作,例如 `jump`、`qrcode`、`html`、`jsapi`
|
||||
* - `pay_params.type`:收银台承接类型,支持 `jump`、`web`、`h5`、`qrcode`、`html`、`jsapi`、`urlscheme`、`mini`、`pos`、`transfer`、`json`、`error`
|
||||
* - `chan_order_no`:渠道订单号,必须返回
|
||||
* - `chan_trade_no`:渠道交易号,可选;未生成时返回空字符串
|
||||
* - `ext_json`:插件私有轻量信息,可选;原始响应不要塞入支付单扩展
|
||||
*
|
||||
* `pay_params` 必须带上对应 `type` 的必要载荷:
|
||||
* - 跳转类:`redirect_url` / `payurl` / `mweb_url`
|
||||
* - 二维码类:`qrcode_text` / `qrcode_data` / `qrcode_url`
|
||||
* - 表单类:`html` 或 `action`
|
||||
* - JSAPI / URL Scheme / 小程序:对应拉起参数或跳转参数
|
||||
*
|
||||
* @param array $order 订单参数
|
||||
* @return array 下单结果
|
||||
*/
|
||||
@@ -31,6 +45,15 @@ interface PaymentInterface
|
||||
/**
|
||||
* 查询订单状态。
|
||||
*
|
||||
* 建议返回当前系统标准结构,定时维护进程会按 `status` 推进支付单:
|
||||
* - `success=true|false`:查询请求是否成功;查询失败不等于支付失败
|
||||
* - `status`:`success` / `failed` / `closed` / `pending`
|
||||
* - `channel_order_no` / `channel_trade_no`:渠道单号
|
||||
* - `channel_status`:渠道原始状态,可选
|
||||
* - `message`:查询说明,可选
|
||||
* - `paid_at` / `failed_at`:终态时间,可选
|
||||
* - `ext_json`:插件私有轻量补充信息,可选
|
||||
*
|
||||
* @param array $order 订单参数
|
||||
* @return array 查询结果
|
||||
*/
|
||||
@@ -57,8 +80,24 @@ interface PaymentInterface
|
||||
/**
|
||||
* 解析并验证支付回调通知。
|
||||
*
|
||||
* 插件应返回当前系统统一可消费的结果结构,核心字段如下:
|
||||
* - `status`:支付状态,限定为 `success` / `failed` / `pending`
|
||||
* - `channel_order_no` / `channel_trade_no`:渠道单号,必须返回
|
||||
* - `channel_status`:渠道原始状态码或状态文本,可选
|
||||
* - `message`:回调处理说明,可选
|
||||
* - `channel_error_code` / `channel_error_msg`:渠道失败原因,可选
|
||||
* - `paid_at` / `failed_at`:支付成功或失败时间,可选
|
||||
* - `fee_actual_amount`:实际手续费,单位分,可选
|
||||
* - `ext_json`:插件私有的轻量补充信息,可选;原始回调和解析结果会进入回调日志,不要塞进支付单扩展
|
||||
*
|
||||
* 插件在验签失败、报文非法或关键字段缺失时,应直接抛出 `PaymentException`。
|
||||
* 只有在回调可信时,才返回标准结果数组。
|
||||
* 如果第三方渠道只返回了一个唯一订单号,插件应同时填充 `channel_order_no` 和 `channel_trade_no`,
|
||||
* 两个字段可以写成相同值。
|
||||
* 业务上尚未终态时返回 `status=pending`,由系统统一记录回调日志而不推进支付单终态。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return array 回调结果
|
||||
* @return array<string, mixed> 回调结果
|
||||
*/
|
||||
public function notify(Request $request): array;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace app\common\payment;
|
||||
|
||||
use app\common\base\BasePayment;
|
||||
use app\common\constant\FileConstant;
|
||||
use app\common\constant\PaymentPluginStatusConstant;
|
||||
use app\common\interface\PaymentInterface;
|
||||
use app\common\interface\PayPluginInterface;
|
||||
use app\common\util\FormatHelper;
|
||||
@@ -324,6 +325,11 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
|
||||
$extra = isset($order['extra']) && is_array($order['extra']) ? $order['extra'] : [];
|
||||
if ($extra !== []) {
|
||||
$context = array_merge($context, $extra);
|
||||
foreach (['merchant', 'payment', 'source'] as $section) {
|
||||
if (isset($extra[$section]) && is_array($extra[$section])) {
|
||||
$context = array_merge($context, $extra[$section]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$param = $this->normalizeParamBag($context['param'] ?? null);
|
||||
@@ -459,7 +465,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
|
||||
} catch (PaymentException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
throw new PaymentException('支付宝下单失败:' . $e->getMessage(), 402, ['order_id' => $orderId]);
|
||||
throw new PaymentException('支付宝下单失败', 402, ['order_id' => $orderId, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +483,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
|
||||
'pay_product' => self::PRODUCT_WEB,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_WEB),
|
||||
'pay_params' => [
|
||||
'type' => 'form',
|
||||
'type' => 'html',
|
||||
'method' => 'POST',
|
||||
'action' => '',
|
||||
'html' => $body,
|
||||
@@ -507,7 +513,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
|
||||
'pay_product' => self::PRODUCT_H5,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_H5),
|
||||
'pay_params' => [
|
||||
'type' => 'form',
|
||||
'type' => 'html',
|
||||
'method' => 'POST',
|
||||
'action' => '',
|
||||
'html' => $body,
|
||||
@@ -733,8 +739,8 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
|
||||
$tradeNo = (string) $this->extractCollectionValue($result, ['trade_no', 'order_id', 'out_biz_no'], '');
|
||||
$totalAmount = (string) $this->extractCollectionValue($result, ['total_amount', 'trans_amount', 'amount'], '0');
|
||||
$status = match ($action) {
|
||||
'transfer' => in_array($tradeStatus, ['SUCCESS', 'PAY_SUCCESS', 'SUCCESSFUL'], true) ? 'success' : $tradeStatus,
|
||||
default => in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true) ? 'success' : $tradeStatus,
|
||||
'transfer' => in_array($tradeStatus, ['SUCCESS', 'PAY_SUCCESS', 'SUCCESSFUL'], true) ? PaymentPluginStatusConstant::SUCCESS : $tradeStatus,
|
||||
default => in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true) ? PaymentPluginStatusConstant::SUCCESS : $tradeStatus,
|
||||
};
|
||||
|
||||
return [
|
||||
@@ -745,7 +751,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
|
||||
'pay_amount' => (int) round(((float) $totalAmount) * 100),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
throw new PaymentException('支付宝查询失败:' . $e->getMessage(), 402);
|
||||
throw new PaymentException('支付宝查询失败', 402, ['order_id' => $outTradeNo, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,7 +780,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
|
||||
Pay::alipay()->close($closeParams);
|
||||
return ['success' => true, 'msg' => '关闭成功', 'pay_product' => $product, 'pay_action' => $action];
|
||||
} catch (\Throwable $e) {
|
||||
throw new PaymentException('支付宝关单失败:' . $e->getMessage(), 402);
|
||||
throw new PaymentException('支付宝关单失败', 402, ['order_id' => $outTradeNo, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,11 +829,11 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
|
||||
'msg' => '退款成功',
|
||||
];
|
||||
}
|
||||
throw new PaymentException($subMsg ?: '退款失败', 402);
|
||||
throw new PaymentException('退款失败', 402, ['order_id' => $outTradeNo, 'sub_msg' => $subMsg]);
|
||||
} catch (PaymentException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
throw new PaymentException('支付宝退款失败:' . $e->getMessage(), 402);
|
||||
throw new PaymentException('支付宝退款失败', 402, ['order_id' => $outTradeNo, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,22 +858,26 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
|
||||
$paidAt = (string) $result->get('gmt_payment', '');
|
||||
|
||||
if (!in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) {
|
||||
throw new PaymentException('回调状态异常:' . $tradeStatus, 402);
|
||||
throw new PaymentException('回调状态异常', 402, ['trade_status' => $tradeStatus]);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => 'success',
|
||||
'pay_order_id' => $outTradeNo,
|
||||
'chan_order_no' => $outTradeNo,
|
||||
'chan_trade_no' => $tradeNo,
|
||||
'amount' => (int) round(((float) $totalAmount) * 100),
|
||||
'paid_at' => $paidAt !== '' ? (FormatHelper::timestamp((int) strtotime($paidAt)) ?: null) : null,
|
||||
'status' => PaymentPluginStatusConstant::SUCCESS,
|
||||
'message' => '支付成功',
|
||||
'channel_order_no' => $outTradeNo,
|
||||
'channel_trade_no' => $tradeNo,
|
||||
'channel_status' => $tradeStatus,
|
||||
'paid_at' => $paidAt !== '' ? (FormatHelper::timestamp((int) strtotime($paidAt)) ?: null) : null,
|
||||
'fee_actual_amount' => null,
|
||||
'ext_json' => [
|
||||
'channel_pay_amount' => (int) round(((float) $totalAmount) * 100),
|
||||
'channel_response' => $result->all(),
|
||||
],
|
||||
];
|
||||
} catch (PaymentException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
throw new PaymentException('支付宝回调验签失败:' . $e->getMessage(), 402);
|
||||
throw new PaymentException('支付宝回调验签失败', 402, ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,6 +901,3 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn
|
||||
return 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
798
app/common/payment/EpayV1Payment.php
Normal file
798
app/common/payment/EpayV1Payment.php
Normal file
@@ -0,0 +1,798 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\payment;
|
||||
|
||||
use app\common\base\BasePayment;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\common\constant\PaymentPluginStatusConstant;
|
||||
use app\common\interface\PaymentInterface;
|
||||
use app\common\interface\PayPluginInterface;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\exception\PaymentException;
|
||||
use app\service\payment\epay\EpaySignerManager;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* ePay V1 网关插件。
|
||||
*
|
||||
* 适用于对接仍提供 V1 协议的第三方平台。
|
||||
*/
|
||||
class EpayV1Payment extends BasePayment implements PaymentInterface, PayPluginInterface
|
||||
{
|
||||
private ?EpaySignerManager $epaySignerManager = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $paymentInfo = [
|
||||
'code' => 'epay_v1',
|
||||
'name' => 'ePay V1 网关',
|
||||
'author' => 'MPAY',
|
||||
'version' => '1.0.0',
|
||||
'pay_types' => ['alipay', 'wxpay'],
|
||||
'transfer_types' => [],
|
||||
'config_schema' => [
|
||||
[
|
||||
'type' => 'input',
|
||||
'field' => 'gateway_url',
|
||||
'title' => '上游网关地址',
|
||||
'value' => '',
|
||||
'props' => [
|
||||
'placeholder' => '例如:https://pay.example.com',
|
||||
],
|
||||
'validate' => [
|
||||
['required' => true, 'message' => '上游网关地址不能为空'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'input',
|
||||
'field' => 'upstream_pid',
|
||||
'title' => '上游商户ID',
|
||||
'value' => '',
|
||||
'props' => [
|
||||
'placeholder' => '请输入第三方平台分配的 pid',
|
||||
],
|
||||
'validate' => [
|
||||
['required' => true, 'message' => '上游商户ID不能为空'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'textarea',
|
||||
'field' => 'upstream_key',
|
||||
'title' => '上游 MD5 密钥',
|
||||
'value' => '',
|
||||
'props' => [
|
||||
'placeholder' => '请输入第三方平台分配的 API Key / KEY',
|
||||
'rows' => 4,
|
||||
],
|
||||
'validate' => [
|
||||
['required' => true, 'message' => '上游 MD5 密钥不能为空'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'input',
|
||||
'field' => 'pay_path',
|
||||
'title' => '下单路径',
|
||||
'value' => '/mapi.php',
|
||||
'props' => [
|
||||
'placeholder' => '默认 /mapi.php',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'input',
|
||||
'field' => 'api_path',
|
||||
'title' => '查询/退款路径',
|
||||
'value' => '/api.php',
|
||||
'props' => [
|
||||
'placeholder' => '默认 /api.php',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'textarea',
|
||||
'field' => 'type_mapping_json',
|
||||
'title' => '支付方式映射',
|
||||
'value' => "{\n \"alipay\": \"alipay\",\n \"wxpay\": \"wxpay\"\n}",
|
||||
'props' => [
|
||||
'placeholder' => 'JSON 格式,例如 {\"wxpay\":\"wxpay\"}',
|
||||
'rows' => 5,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
public function init(array $channelConfig): void
|
||||
{
|
||||
parent::init($channelConfig);
|
||||
}
|
||||
|
||||
public function pay(array $order): array
|
||||
{
|
||||
$payload = [
|
||||
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
|
||||
'type' => $this->resolveUpstreamType($order, [
|
||||
'alipay' => 'alipay',
|
||||
'wxpay' => 'wxpay',
|
||||
]),
|
||||
'out_trade_no' => $this->resolveOrderNo($order),
|
||||
'notify_url' => trim((string) ($order['callback_url'] ?? '')),
|
||||
'return_url' => trim((string) ($order['return_url'] ?? '')),
|
||||
'name' => $this->resolveSubject($order),
|
||||
'money' => $this->amountToMoney($this->resolveAmount($order)),
|
||||
'clientip' => trim((string) ($order['client_ip'] ?? '127.0.0.1')),
|
||||
'device' => $this->resolveDevice($order),
|
||||
];
|
||||
|
||||
$param = $this->resolveParamValue($order);
|
||||
if ($param !== '') {
|
||||
$payload['param'] = $param;
|
||||
}
|
||||
|
||||
$payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_MD5, $this->requireConfigValue('upstream_key', '上游 MD5 密钥'));
|
||||
$response = $this->isMockEnabled()
|
||||
? $this->buildMockPayResponse($payload, $order)
|
||||
: $this->requestFormJson('POST', $this->resolveGatewayUrl('pay_path', '/mapi.php'), $payload);
|
||||
|
||||
if ((int) ($response['code'] ?? 0) !== 1) {
|
||||
throw new PaymentException((string) ($response['msg'] ?? '上游 V1 下单失败'), 40200, [
|
||||
'response' => $response,
|
||||
]);
|
||||
}
|
||||
|
||||
$channelNos = $this->resolveChannelNos($response + [
|
||||
'trade_no' => (string) ($response['trade_no'] ?? $payload['out_trade_no']),
|
||||
]);
|
||||
$payParams = $this->normalizePayResponse($response);
|
||||
|
||||
return [
|
||||
'pay_product' => (string) $payload['type'],
|
||||
'pay_action' => (string) ($payParams['type'] ?? ''),
|
||||
'pay_params' => $payParams,
|
||||
'chan_order_no' => $channelNos['channel_order_no'],
|
||||
'chan_trade_no' => $channelNos['channel_trade_no'],
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $order): array
|
||||
{
|
||||
$payload = [
|
||||
'act' => 'order',
|
||||
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
|
||||
'key' => $this->requireConfigValue('upstream_key', '上游 MD5 密钥'),
|
||||
];
|
||||
|
||||
$tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? ''));
|
||||
if ($tradeNo !== '') {
|
||||
$payload['trade_no'] = $tradeNo;
|
||||
} else {
|
||||
$payload['out_trade_no'] = $this->resolveOrderNo($order);
|
||||
}
|
||||
|
||||
$response = $this->isMockEnabled()
|
||||
? $this->buildMockQueryResponse($order)
|
||||
: $this->requestQueryJson($this->resolveGatewayUrl('api_path', '/api.php'), $payload);
|
||||
if ((int) ($response['code'] ?? 0) !== 1) {
|
||||
return [
|
||||
'success' => false,
|
||||
'msg' => (string) ($response['msg'] ?? '上游 V1 查单失败'),
|
||||
'raw_data' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
$channelNos = $this->resolveChannelNos($response);
|
||||
$status = (int) ($response['status'] ?? 0) === 1
|
||||
? PaymentPluginStatusConstant::SUCCESS
|
||||
: PaymentPluginStatusConstant::PENDING;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $status,
|
||||
'channel_order_no' => $channelNos['channel_order_no'],
|
||||
'channel_trade_no' => $channelNos['channel_trade_no'],
|
||||
'channel_status' => (string) ($response['status'] ?? ''),
|
||||
'paid_at' => $response['endtime'] ?? null,
|
||||
'ext_json' => [
|
||||
'channel_response' => $response,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function close(array $order): array
|
||||
{
|
||||
throw new PaymentException('上游 ePay V1 协议不支持关单', 40200, [
|
||||
'plugin_code' => $this->getCode(),
|
||||
'order_no' => $this->resolveOrderNo($order),
|
||||
]);
|
||||
}
|
||||
|
||||
public function refund(array $order): array
|
||||
{
|
||||
$payload = [
|
||||
'act' => 'refund',
|
||||
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
|
||||
'key' => $this->requireConfigValue('upstream_key', '上游 MD5 密钥'),
|
||||
'money' => $this->amountToMoney((int) ($order['refund_amount'] ?? 0)),
|
||||
];
|
||||
|
||||
$tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? ''));
|
||||
if ($tradeNo !== '') {
|
||||
$payload['trade_no'] = $tradeNo;
|
||||
} else {
|
||||
$payload['out_trade_no'] = $this->resolveOrderNo($order);
|
||||
}
|
||||
|
||||
$response = $this->isMockEnabled()
|
||||
? $this->buildMockRefundResponse($order)
|
||||
: $this->requestFormJson('POST', $this->resolveGatewayUrl('api_path', '/api.php'), $payload);
|
||||
if ((int) ($response['code'] ?? 0) !== 1) {
|
||||
return [
|
||||
'success' => false,
|
||||
'msg' => (string) ($response['msg'] ?? '上游 V1 退款失败'),
|
||||
'raw_data' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'msg' => (string) ($response['msg'] ?? 'success'),
|
||||
'chan_refund_no' => trim((string) ($response['refund_no'] ?? $response['trade_no'] ?? '')),
|
||||
'raw_data' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
public function notify(Request $request): array
|
||||
{
|
||||
$payload = $this->resolveNotifyPayload($request);
|
||||
$this->verifyPayloadSignature(
|
||||
$payload,
|
||||
AuthConstant::API_SIGN_NAME_MD5,
|
||||
$this->requireConfigValue('upstream_key', '上游 MD5 密钥'),
|
||||
'上游 V1 回调验签失败'
|
||||
);
|
||||
|
||||
$channelNos = $this->resolveChannelNos($payload);
|
||||
$status = $this->normalizeNotifyStatus((string) ($payload['trade_status'] ?? ''));
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'message' => (string) ($payload['trade_status'] ?? ''),
|
||||
'channel_order_no' => $channelNos['channel_order_no'],
|
||||
'channel_trade_no' => $channelNos['channel_trade_no'],
|
||||
'channel_status' => (string) ($payload['trade_status'] ?? ''),
|
||||
'paid_at' => $payload['endtime'] ?? null,
|
||||
'ext_json' => [
|
||||
'channel_type' => (string) ($payload['type'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function notifySuccess(): string|Response
|
||||
{
|
||||
return 'success';
|
||||
}
|
||||
|
||||
public function notifyFail(): string|Response
|
||||
{
|
||||
return 'fail';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签名管理器。
|
||||
*/
|
||||
private function signerManager(): EpaySignerManager
|
||||
{
|
||||
if ($this->epaySignerManager === null) {
|
||||
/** @var EpaySignerManager $manager */
|
||||
$manager = container_make(EpaySignerManager::class, []);
|
||||
$this->epaySignerManager = $manager;
|
||||
}
|
||||
|
||||
return $this->epaySignerManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否启用插件内置 mock。
|
||||
*/
|
||||
private function isMockEnabled(): bool
|
||||
{
|
||||
$value = $this->getConfig('mock_enabled', false);
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value === 1;
|
||||
}
|
||||
|
||||
$filtered = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
|
||||
return $filtered ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取必填配置。
|
||||
*/
|
||||
private function requireConfigValue(string $key, string $label): string
|
||||
{
|
||||
$value = trim((string) $this->getConfig($key, ''));
|
||||
if ($value === '') {
|
||||
throw new PaymentException($label . '未配置', 40200, [
|
||||
'config_key' => $key,
|
||||
]);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建上游接口地址。
|
||||
*/
|
||||
private function resolveGatewayUrl(string $pathConfigKey, string $defaultPath): string
|
||||
{
|
||||
$baseUrl = rtrim($this->requireConfigValue('gateway_url', '上游网关地址'), '/');
|
||||
$path = trim((string) $this->getConfig($pathConfigKey, $defaultPath));
|
||||
if ($path === '') {
|
||||
$path = $defaultPath;
|
||||
}
|
||||
|
||||
if (preg_match('/^https?:\/\//i', $path) === 1) {
|
||||
return rtrim($path, '/');
|
||||
}
|
||||
|
||||
return $baseUrl . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function requestFormJson(string $method, string $url, array $payload): array
|
||||
{
|
||||
$response = $this->request($method, $url, [
|
||||
'form_params' => $payload,
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
return $this->decodeJsonResponse((string) $response->getBody(), $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function requestQueryJson(string $url, array $query): array
|
||||
{
|
||||
$response = $this->request('GET', $url, [
|
||||
'query' => $query,
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
return $this->decodeJsonResponse((string) $response->getBody(), $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeJsonResponse(string $body, string $url): array
|
||||
{
|
||||
$decoded = json_decode($body, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new PaymentException('上游网关响应不是合法 JSON', 40200, [
|
||||
'url' => $url,
|
||||
'body_excerpt' => $this->clipText($body),
|
||||
]);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function signPayload(array $payload, string $signType, string $key): array
|
||||
{
|
||||
$payload['sign_type'] = $signType;
|
||||
$payload['sign'] = $this->signerManager()->sign($payload, $signType, $key);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function verifyPayloadSignature(array $payload, string $defaultSignType, string $key, string $message): void
|
||||
{
|
||||
$sign = trim((string) ($payload['sign'] ?? ''));
|
||||
if ($sign === '') {
|
||||
throw new PaymentException($message, 40200, ['reason' => 'missing_sign']);
|
||||
}
|
||||
|
||||
$signType = trim((string) ($payload['sign_type'] ?? $defaultSignType));
|
||||
if (!$this->signerManager()->verify($payload, $signType, $sign, $key)) {
|
||||
throw new PaymentException($message, 40200, [
|
||||
'sign_type' => $signType,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $defaultMapping
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function resolveTypeMapping(array $defaultMapping): array
|
||||
{
|
||||
$raw = $this->getConfig('type_mapping_json', '');
|
||||
$mapping = $defaultMapping;
|
||||
|
||||
if (is_array($raw)) {
|
||||
foreach ($raw as $key => $value) {
|
||||
$source = strtolower(trim((string) $key));
|
||||
$target = strtolower(trim((string) $value));
|
||||
if ($source !== '' && $target !== '') {
|
||||
$mapping[$source] = $target;
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
$text = trim((string) $raw);
|
||||
if ($text === '') {
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
$decoded = json_decode($text, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new PaymentException('支付方式映射配置不是合法 JSON', 40200, [
|
||||
'config_key' => 'type_mapping_json',
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($decoded as $key => $value) {
|
||||
$source = strtolower(trim((string) $key));
|
||||
$target = strtolower(trim((string) $value));
|
||||
if ($source !== '' && $target !== '') {
|
||||
$mapping[$source] = $target;
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $defaultMapping
|
||||
*/
|
||||
private function resolveUpstreamType(array $order, array $defaultMapping): string
|
||||
{
|
||||
$payTypeCode = strtolower(trim((string) ($order['pay_type_code'] ?? '')));
|
||||
if ($payTypeCode === '') {
|
||||
throw new PaymentException('订单缺少支付方式编码', 40200);
|
||||
}
|
||||
|
||||
$mapping = $this->resolveTypeMapping($defaultMapping);
|
||||
$upstreamType = strtolower(trim((string) ($mapping[$payTypeCode] ?? '')));
|
||||
if ($upstreamType === '') {
|
||||
throw new PaymentException('未配置上游支付方式映射', 40200, [
|
||||
'pay_type_code' => $payTypeCode,
|
||||
]);
|
||||
}
|
||||
|
||||
return $upstreamType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台内部支付单号,作为上游商户订单号。
|
||||
*/
|
||||
private function resolveOrderNo(array $order): string
|
||||
{
|
||||
$orderNo = trim((string) ($order['order_id'] ?? $order['pay_no'] ?? $order['out_trade_no'] ?? ''));
|
||||
if ($orderNo === '') {
|
||||
throw new PaymentException('订单缺少订单号', 40200);
|
||||
}
|
||||
|
||||
return $orderNo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付金额(分)。
|
||||
*/
|
||||
private function resolveAmount(array $order): int
|
||||
{
|
||||
$amount = (int) ($order['amount'] ?? $order['pay_amount'] ?? 0);
|
||||
if ($amount <= 0) {
|
||||
throw new PaymentException('订单金额不合法', 40200);
|
||||
}
|
||||
|
||||
return $amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单标题。
|
||||
*/
|
||||
private function resolveSubject(array $order): string
|
||||
{
|
||||
$subject = trim((string) ($order['subject'] ?? $order['body'] ?? ''));
|
||||
if ($subject === '') {
|
||||
throw new PaymentException('订单标题不能为空', 40200);
|
||||
}
|
||||
|
||||
if (function_exists('mb_strcut')) {
|
||||
return mb_strcut($subject, 0, 127, 'UTF-8');
|
||||
}
|
||||
|
||||
return substr($subject, 0, 127);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveExtraContext(array $order): array
|
||||
{
|
||||
$context = [];
|
||||
foreach ([$order['extra'] ?? null, $order['param'] ?? null] as $bag) {
|
||||
if (is_array($bag)) {
|
||||
$context = array_merge($context, $bag);
|
||||
foreach (['merchant', 'payment', 'source'] as $section) {
|
||||
if (isset($bag[$section]) && is_array($bag[$section])) {
|
||||
$context = array_merge($context, $bag[$section]);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_string($bag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = trim($bag);
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded = json_decode($text, true);
|
||||
if (is_array($decoded)) {
|
||||
$context = array_merge($context, $decoded);
|
||||
continue;
|
||||
}
|
||||
|
||||
parse_str($text, $parsed);
|
||||
if (is_array($parsed) && $parsed !== []) {
|
||||
$context = array_merge($context, $parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化备注透传字段。
|
||||
*/
|
||||
private function resolveParamValue(array $order): string
|
||||
{
|
||||
$context = $this->resolveExtraContext($order);
|
||||
$param = $context['param'] ?? null;
|
||||
if ($param === null || $param === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_scalar($param)) {
|
||||
return trim((string) $param);
|
||||
}
|
||||
|
||||
$json = json_encode($param, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $json !== false ? $json : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化客户端环境。
|
||||
*/
|
||||
private function resolveDevice(array $order, string $default = 'pc'): string
|
||||
{
|
||||
$device = strtolower(trim((string) ($order['_env'] ?? $order['device'] ?? '')));
|
||||
return $device !== '' ? $device : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取回调入参。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveNotifyPayload(Request $request): array
|
||||
{
|
||||
$payload = array_merge((array) $request->get(), (array) $request->post());
|
||||
if ($payload !== []) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$all = $request->all();
|
||||
|
||||
return is_array($all) ? $all : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将分转换为元字符串。
|
||||
*/
|
||||
private function amountToMoney(int $amount): string
|
||||
{
|
||||
return FormatHelper::amount($amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{channel_order_no: string, channel_trade_no: string}
|
||||
*/
|
||||
private function resolveChannelNos(array $payload): array
|
||||
{
|
||||
$channelOrderNo = trim((string) ($payload['trade_no'] ?? $payload['transaction_id'] ?? ''));
|
||||
$channelTradeNo = trim((string) ($payload['api_trade_no'] ?? $payload['channel_trade_no'] ?? ''));
|
||||
|
||||
if ($channelOrderNo === '' && $channelTradeNo === '') {
|
||||
throw new PaymentException('上游返回缺少渠道订单号', 40200);
|
||||
}
|
||||
|
||||
if ($channelOrderNo === '') {
|
||||
$channelOrderNo = $channelTradeNo;
|
||||
}
|
||||
if ($channelTradeNo === '') {
|
||||
$channelTradeNo = $channelOrderNo;
|
||||
}
|
||||
|
||||
return [
|
||||
'channel_order_no' => $channelOrderNo,
|
||||
'channel_trade_no' => $channelTradeNo,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化回调支付状态。
|
||||
*/
|
||||
private function normalizeNotifyStatus(string $tradeStatus): string
|
||||
{
|
||||
$tradeStatus = strtoupper(trim($tradeStatus));
|
||||
if (in_array($tradeStatus, ['TRADE_SUCCESS', 'SUCCESS', 'PAY_SUCCESS', 'FINISHED'], true)) {
|
||||
return PaymentPluginStatusConstant::SUCCESS;
|
||||
}
|
||||
|
||||
if (in_array($tradeStatus, ['TRADE_FAIL', 'FAILED', 'TRADE_CLOSED', 'CLOSED', 'PAYERROR'], true)) {
|
||||
return PaymentPluginStatusConstant::FAILED;
|
||||
}
|
||||
|
||||
return PaymentPluginStatusConstant::PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成响应文本摘要。
|
||||
*/
|
||||
private function clipText(string $text, int $length = 240): string
|
||||
{
|
||||
$text = trim(preg_replace('/\s+/', ' ', $text) ?? $text);
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return strlen($text) <= $length ? $text : substr($text, 0, $length) . '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildMockPayResponse(array $payload, array $order): array
|
||||
{
|
||||
$seed = strtolower((string) ($payload['out_trade_no'] ?? $this->resolveOrderNo($order)));
|
||||
$channelOrderNo = 'V1ORD' . strtoupper(substr(md5($seed), 0, 16));
|
||||
$channelTradeNo = 'V1TRD' . strtoupper(substr(sha1($seed), 0, 16));
|
||||
|
||||
return [
|
||||
'code' => 1,
|
||||
'msg' => 'success',
|
||||
'trade_no' => $channelOrderNo,
|
||||
'api_trade_no' => $channelTradeNo,
|
||||
'payurl' => $this->resolveMockJumpUrl($channelTradeNo),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildMockQueryResponse(array $order): array
|
||||
{
|
||||
$channelOrderNo = trim((string) ($order['chan_order_no'] ?? ''));
|
||||
$channelTradeNo = trim((string) ($order['chan_trade_no'] ?? ''));
|
||||
if ($channelOrderNo === '' && $channelTradeNo === '') {
|
||||
$seed = strtolower($this->resolveOrderNo($order));
|
||||
$channelOrderNo = 'V1ORD' . strtoupper(substr(md5($seed), 0, 16));
|
||||
$channelTradeNo = 'V1TRD' . strtoupper(substr(sha1($seed), 0, 16));
|
||||
} elseif ($channelOrderNo === '') {
|
||||
$channelOrderNo = $channelTradeNo;
|
||||
} elseif ($channelTradeNo === '') {
|
||||
$channelTradeNo = $channelOrderNo;
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 1,
|
||||
'msg' => '查询订单成功',
|
||||
'trade_no' => $channelOrderNo,
|
||||
'api_trade_no' => $channelTradeNo,
|
||||
'out_trade_no' => $this->resolveOrderNo($order),
|
||||
'status' => 1,
|
||||
'buyer' => 'MOCK_V1_BUYER',
|
||||
'param' => $this->resolveParamValue($order),
|
||||
'endtime' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildMockRefundResponse(array $order): array
|
||||
{
|
||||
$seed = strtolower(trim((string) ($order['refund_no'] ?? $this->resolveOrderNo($order))));
|
||||
|
||||
return [
|
||||
'code' => 1,
|
||||
'msg' => '退款成功',
|
||||
'refund_no' => 'V1REF' . strtoupper(substr(md5($seed), 0, 16)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 mock 跳转地址。
|
||||
*/
|
||||
private function resolveMockJumpUrl(string $channelTradeNo): string
|
||||
{
|
||||
$baseUrl = trim((string) $this->getConfig('mock_jump_base_url', 'https://mock.epay.test/pay/v1'));
|
||||
if ($baseUrl === '') {
|
||||
$baseUrl = 'https://mock.epay.test/pay/v1';
|
||||
}
|
||||
|
||||
return rtrim($baseUrl, '/') . '?trade_no=' . rawurlencode($channelTradeNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $response
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizePayResponse(array $response): array
|
||||
{
|
||||
$payUrl = trim((string) ($response['payurl'] ?? ''));
|
||||
if ($payUrl !== '') {
|
||||
return [
|
||||
'type' => 'jump',
|
||||
'payurl' => $payUrl,
|
||||
'redirect_url' => $payUrl,
|
||||
];
|
||||
}
|
||||
|
||||
$qrcode = trim((string) ($response['qrcode'] ?? ''));
|
||||
if ($qrcode !== '') {
|
||||
return [
|
||||
'type' => 'qrcode',
|
||||
'qrcode' => $qrcode,
|
||||
'qrcode_text' => $qrcode,
|
||||
];
|
||||
}
|
||||
|
||||
$urlscheme = trim((string) ($response['urlscheme'] ?? ''));
|
||||
if ($urlscheme !== '') {
|
||||
return [
|
||||
'type' => 'urlscheme',
|
||||
'urlscheme' => $urlscheme,
|
||||
'redirect_url' => $urlscheme,
|
||||
];
|
||||
}
|
||||
|
||||
throw new PaymentException('上游 V1 未返回有效支付内容', 40200, [
|
||||
'response' => $response,
|
||||
]);
|
||||
}
|
||||
}
|
||||
961
app/common/payment/EpayV2Payment.php
Normal file
961
app/common/payment/EpayV2Payment.php
Normal file
@@ -0,0 +1,961 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\payment;
|
||||
|
||||
use app\common\base\BasePayment;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\common\constant\PaymentPluginStatusConstant;
|
||||
use app\common\interface\PaymentInterface;
|
||||
use app\common\interface\PayPluginInterface;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\exception\PaymentException;
|
||||
use app\service\payment\epay\EpaySignerManager;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* ePay V2 网关插件。
|
||||
*
|
||||
* 适用于对接已升级为 V2 协议的第三方平台。
|
||||
*/
|
||||
class EpayV2Payment extends BasePayment implements PaymentInterface, PayPluginInterface
|
||||
{
|
||||
private ?EpaySignerManager $epaySignerManager = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $paymentInfo = [
|
||||
'code' => 'epay_v2',
|
||||
'name' => 'ePay V2 网关',
|
||||
'author' => 'MPAY',
|
||||
'version' => '1.0.0',
|
||||
'pay_types' => ['alipay', 'wxpay', 'unionpay'],
|
||||
'transfer_types' => [],
|
||||
'config_schema' => [
|
||||
[
|
||||
'type' => 'input',
|
||||
'field' => 'gateway_url',
|
||||
'title' => '上游网关地址',
|
||||
'value' => '',
|
||||
'props' => [
|
||||
'placeholder' => '例如:https://pay.example.com',
|
||||
],
|
||||
'validate' => [
|
||||
['required' => true, 'message' => '上游网关地址不能为空'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'input',
|
||||
'field' => 'upstream_pid',
|
||||
'title' => '上游商户ID',
|
||||
'value' => '',
|
||||
'props' => [
|
||||
'placeholder' => '请输入第三方平台分配的 pid',
|
||||
],
|
||||
'validate' => [
|
||||
['required' => true, 'message' => '上游商户ID不能为空'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'textarea',
|
||||
'field' => 'merchant_private_key',
|
||||
'title' => '上游商户私钥',
|
||||
'value' => '',
|
||||
'props' => [
|
||||
'placeholder' => '请输入对接上游 V2 的商户 RSA 私钥',
|
||||
'rows' => 6,
|
||||
],
|
||||
'validate' => [
|
||||
['required' => true, 'message' => '上游商户私钥不能为空'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'textarea',
|
||||
'field' => 'platform_public_key',
|
||||
'title' => '上游平台公钥',
|
||||
'value' => '',
|
||||
'props' => [
|
||||
'placeholder' => '请输入上游平台 RSA 公钥',
|
||||
'rows' => 6,
|
||||
],
|
||||
'validate' => [
|
||||
['required' => true, 'message' => '上游平台公钥不能为空'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'input',
|
||||
'field' => 'create_path',
|
||||
'title' => '下单路径',
|
||||
'value' => '/api/pay/create',
|
||||
'props' => [
|
||||
'placeholder' => '默认 /api/pay/create',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'input',
|
||||
'field' => 'query_path',
|
||||
'title' => '查单路径',
|
||||
'value' => '/api/pay/query',
|
||||
'props' => [
|
||||
'placeholder' => '默认 /api/pay/query',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'input',
|
||||
'field' => 'refund_path',
|
||||
'title' => '退款路径',
|
||||
'value' => '/api/pay/refund',
|
||||
'props' => [
|
||||
'placeholder' => '默认 /api/pay/refund',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'input',
|
||||
'field' => 'close_path',
|
||||
'title' => '关单路径',
|
||||
'value' => '/api/pay/close',
|
||||
'props' => [
|
||||
'placeholder' => '默认 /api/pay/close',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'textarea',
|
||||
'field' => 'type_mapping_json',
|
||||
'title' => '支付方式映射',
|
||||
'value' => "{\n \"alipay\": \"alipay\",\n \"wxpay\": \"wxpay\",\n \"unionpay\": \"bank\"\n}",
|
||||
'props' => [
|
||||
'placeholder' => 'JSON 格式,例如 {\"unionpay\":\"bank\"}',
|
||||
'rows' => 6,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
public function init(array $channelConfig): void
|
||||
{
|
||||
parent::init($channelConfig);
|
||||
}
|
||||
|
||||
public function pay(array $order): array
|
||||
{
|
||||
$payload = [
|
||||
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
|
||||
'method' => $this->resolveV2Method($order),
|
||||
'type' => $this->resolveUpstreamType($order, [
|
||||
'alipay' => 'alipay',
|
||||
'wxpay' => 'wxpay',
|
||||
'unionpay' => 'bank',
|
||||
]),
|
||||
'out_trade_no' => $this->resolveOrderNo($order),
|
||||
'notify_url' => trim((string) ($order['callback_url'] ?? '')),
|
||||
'name' => $this->resolveSubject($order),
|
||||
'money' => $this->amountToMoney($this->resolveAmount($order)),
|
||||
'timestamp' => (string) time(),
|
||||
'clientip' => trim((string) ($order['client_ip'] ?? '127.0.0.1')),
|
||||
];
|
||||
|
||||
$returnUrl = trim((string) ($order['return_url'] ?? ''));
|
||||
if ($returnUrl !== '') {
|
||||
$payload['return_url'] = $returnUrl;
|
||||
}
|
||||
|
||||
$device = $this->resolveDevice($order);
|
||||
if ($device !== '') {
|
||||
$payload['device'] = $device;
|
||||
}
|
||||
|
||||
$param = $this->resolveParamValue($order);
|
||||
if ($param !== '') {
|
||||
$payload['param'] = $param;
|
||||
}
|
||||
|
||||
$context = $this->resolveExtraContext($order);
|
||||
foreach (['auth_code', 'sub_openid', 'sub_appid'] as $key) {
|
||||
$value = trim((string) ($context[$key] ?? ''));
|
||||
if ($value !== '') {
|
||||
$payload[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $this->requireConfigValue('merchant_private_key', '上游商户私钥'));
|
||||
$response = $this->isMockEnabled()
|
||||
? $this->buildMockPayResponse($payload, $order)
|
||||
: $this->requestFormJson('POST', $this->resolveGatewayUrl('create_path', '/api/pay/create'), $payload);
|
||||
$this->verifyPayloadSignature(
|
||||
$response,
|
||||
AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
|
||||
$this->requireConfigValue('platform_public_key', '上游平台公钥'),
|
||||
'上游 V2 下单响应验签失败'
|
||||
);
|
||||
|
||||
if ((int) ($response['code'] ?? -1) !== 0) {
|
||||
throw new PaymentException((string) ($response['msg'] ?? '上游 V2 下单失败'), 40200, [
|
||||
'response' => $response,
|
||||
]);
|
||||
}
|
||||
|
||||
$channelNos = $this->resolveChannelNos($response + [
|
||||
'trade_no' => (string) ($response['trade_no'] ?? $payload['out_trade_no']),
|
||||
]);
|
||||
$payType = strtolower(trim((string) ($response['pay_type'] ?? '')));
|
||||
$payParams = $this->normalizePayResponse($payType, $response['pay_info'] ?? null);
|
||||
|
||||
return [
|
||||
'pay_product' => (string) $payload['type'],
|
||||
'pay_action' => (string) ($payParams['type'] ?? $payType),
|
||||
'pay_params' => $payParams,
|
||||
'chan_order_no' => $channelNos['channel_order_no'],
|
||||
'chan_trade_no' => $channelNos['channel_trade_no'],
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $order): array
|
||||
{
|
||||
$payload = [
|
||||
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
|
||||
'timestamp' => (string) time(),
|
||||
];
|
||||
|
||||
$tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? ''));
|
||||
if ($tradeNo !== '') {
|
||||
$payload['trade_no'] = $tradeNo;
|
||||
} else {
|
||||
$payload['out_trade_no'] = $this->resolveOrderNo($order);
|
||||
}
|
||||
|
||||
$payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $this->requireConfigValue('merchant_private_key', '上游商户私钥'));
|
||||
$response = $this->isMockEnabled()
|
||||
? $this->buildMockQueryResponse($order)
|
||||
: $this->requestFormJson('POST', $this->resolveGatewayUrl('query_path', '/api/pay/query'), $payload);
|
||||
$this->verifyPayloadSignature(
|
||||
$response,
|
||||
AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
|
||||
$this->requireConfigValue('platform_public_key', '上游平台公钥'),
|
||||
'上游 V2 查单响应验签失败'
|
||||
);
|
||||
|
||||
if ((int) ($response['code'] ?? -1) !== 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'msg' => (string) ($response['msg'] ?? '上游 V2 查单失败'),
|
||||
'raw_data' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
$channelNos = $this->resolveChannelNos($response);
|
||||
$statusCode = (int) ($response['status'] ?? 0);
|
||||
$status = match ($statusCode) {
|
||||
1, 2 => PaymentPluginStatusConstant::SUCCESS,
|
||||
default => PaymentPluginStatusConstant::PENDING,
|
||||
};
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $status,
|
||||
'channel_order_no' => $channelNos['channel_order_no'],
|
||||
'channel_trade_no' => $channelNos['channel_trade_no'],
|
||||
'channel_status' => (string) $statusCode,
|
||||
'paid_at' => $response['endtime'] ?? null,
|
||||
'ext_json' => [
|
||||
'refundmoney' => (string) ($response['refundmoney'] ?? ''),
|
||||
'channel_response' => $response,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function close(array $order): array
|
||||
{
|
||||
$payload = [
|
||||
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
|
||||
'timestamp' => (string) time(),
|
||||
];
|
||||
|
||||
$tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? ''));
|
||||
if ($tradeNo !== '') {
|
||||
$payload['trade_no'] = $tradeNo;
|
||||
} else {
|
||||
$payload['out_trade_no'] = $this->resolveOrderNo($order);
|
||||
}
|
||||
|
||||
$payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $this->requireConfigValue('merchant_private_key', '上游商户私钥'));
|
||||
$response = $this->isMockEnabled()
|
||||
? $this->buildMockCloseResponse($order)
|
||||
: $this->requestFormJson('POST', $this->resolveGatewayUrl('close_path', '/api/pay/close'), $payload);
|
||||
$this->verifyPayloadSignature(
|
||||
$response,
|
||||
AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
|
||||
$this->requireConfigValue('platform_public_key', '上游平台公钥'),
|
||||
'上游 V2 关单响应验签失败'
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => (int) ($response['code'] ?? -1) === 0,
|
||||
'msg' => (string) ($response['msg'] ?? ''),
|
||||
'raw_data' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
public function refund(array $order): array
|
||||
{
|
||||
$payload = [
|
||||
'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'),
|
||||
'money' => $this->amountToMoney((int) ($order['refund_amount'] ?? 0)),
|
||||
'timestamp' => (string) time(),
|
||||
];
|
||||
|
||||
$tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? ''));
|
||||
if ($tradeNo !== '') {
|
||||
$payload['trade_no'] = $tradeNo;
|
||||
} else {
|
||||
$payload['out_trade_no'] = $this->resolveOrderNo($order);
|
||||
}
|
||||
|
||||
$outRefundNo = trim((string) ($order['refund_no'] ?? ''));
|
||||
if ($outRefundNo !== '') {
|
||||
$payload['out_refund_no'] = $outRefundNo;
|
||||
}
|
||||
|
||||
$payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $this->requireConfigValue('merchant_private_key', '上游商户私钥'));
|
||||
$response = $this->isMockEnabled()
|
||||
? $this->buildMockRefundResponse($order)
|
||||
: $this->requestFormJson('POST', $this->resolveGatewayUrl('refund_path', '/api/pay/refund'), $payload);
|
||||
$this->verifyPayloadSignature(
|
||||
$response,
|
||||
AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
|
||||
$this->requireConfigValue('platform_public_key', '上游平台公钥'),
|
||||
'上游 V2 退款响应验签失败'
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => (int) ($response['code'] ?? -1) === 0,
|
||||
'msg' => (string) ($response['msg'] ?? ''),
|
||||
'chan_refund_no' => trim((string) ($response['refund_no'] ?? $response['out_refund_no'] ?? '')),
|
||||
'raw_data' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
public function notify(Request $request): array
|
||||
{
|
||||
$payload = $this->resolveNotifyPayload($request);
|
||||
$this->verifyPayloadSignature(
|
||||
$payload,
|
||||
AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
|
||||
$this->requireConfigValue('platform_public_key', '上游平台公钥'),
|
||||
'上游 V2 回调验签失败'
|
||||
);
|
||||
|
||||
$channelNos = $this->resolveChannelNos($payload);
|
||||
$status = $this->normalizeNotifyStatus((string) ($payload['trade_status'] ?? ''));
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'message' => (string) ($payload['trade_status'] ?? ''),
|
||||
'channel_order_no' => $channelNos['channel_order_no'],
|
||||
'channel_trade_no' => $channelNos['channel_trade_no'],
|
||||
'channel_status' => (string) ($payload['trade_status'] ?? ''),
|
||||
'paid_at' => $payload['endtime'] ?? null,
|
||||
'ext_json' => [
|
||||
'channel_type' => (string) ($payload['type'] ?? ''),
|
||||
'timestamp' => (string) ($payload['timestamp'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function notifySuccess(): string|Response
|
||||
{
|
||||
return 'success';
|
||||
}
|
||||
|
||||
public function notifyFail(): string|Response
|
||||
{
|
||||
return 'fail';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签名管理器。
|
||||
*/
|
||||
private function signerManager(): EpaySignerManager
|
||||
{
|
||||
if ($this->epaySignerManager === null) {
|
||||
/** @var EpaySignerManager $manager */
|
||||
$manager = container_make(EpaySignerManager::class, []);
|
||||
$this->epaySignerManager = $manager;
|
||||
}
|
||||
|
||||
return $this->epaySignerManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否启用插件内置 mock。
|
||||
*/
|
||||
private function isMockEnabled(): bool
|
||||
{
|
||||
$value = $this->getConfig('mock_enabled', false);
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value === 1;
|
||||
}
|
||||
|
||||
$filtered = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
|
||||
return $filtered ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取必填配置。
|
||||
*/
|
||||
private function requireConfigValue(string $key, string $label): string
|
||||
{
|
||||
$value = trim((string) $this->getConfig($key, ''));
|
||||
if ($value === '') {
|
||||
throw new PaymentException($label . '未配置', 40200, [
|
||||
'config_key' => $key,
|
||||
]);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建上游接口地址。
|
||||
*/
|
||||
private function resolveGatewayUrl(string $pathConfigKey, string $defaultPath): string
|
||||
{
|
||||
$baseUrl = rtrim($this->requireConfigValue('gateway_url', '上游网关地址'), '/');
|
||||
$path = trim((string) $this->getConfig($pathConfigKey, $defaultPath));
|
||||
if ($path === '') {
|
||||
$path = $defaultPath;
|
||||
}
|
||||
|
||||
if (preg_match('/^https?:\/\//i', $path) === 1) {
|
||||
return rtrim($path, '/');
|
||||
}
|
||||
|
||||
return $baseUrl . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function requestFormJson(string $method, string $url, array $payload): array
|
||||
{
|
||||
$response = $this->request($method, $url, [
|
||||
'form_params' => $payload,
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
return $this->decodeJsonResponse((string) $response->getBody(), $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeJsonResponse(string $body, string $url): array
|
||||
{
|
||||
$decoded = json_decode($body, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new PaymentException('上游网关响应不是合法 JSON', 40200, [
|
||||
'url' => $url,
|
||||
'body_excerpt' => $this->clipText($body),
|
||||
]);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function signPayload(array $payload, string $signType, string $key): array
|
||||
{
|
||||
$payload['sign_type'] = $signType;
|
||||
$payload['sign'] = $this->signerManager()->sign($payload, $signType, $key);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function verifyPayloadSignature(array $payload, string $defaultSignType, string $key, string $message): void
|
||||
{
|
||||
$sign = trim((string) ($payload['sign'] ?? ''));
|
||||
if ($sign === '') {
|
||||
throw new PaymentException($message, 40200, ['reason' => 'missing_sign']);
|
||||
}
|
||||
|
||||
$signType = trim((string) ($payload['sign_type'] ?? $defaultSignType));
|
||||
if (!$this->signerManager()->verify($payload, $signType, $sign, $key)) {
|
||||
throw new PaymentException($message, 40200, [
|
||||
'sign_type' => $signType,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $defaultMapping
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function resolveTypeMapping(array $defaultMapping): array
|
||||
{
|
||||
$raw = $this->getConfig('type_mapping_json', '');
|
||||
$mapping = $defaultMapping;
|
||||
|
||||
if (is_array($raw)) {
|
||||
foreach ($raw as $key => $value) {
|
||||
$source = strtolower(trim((string) $key));
|
||||
$target = strtolower(trim((string) $value));
|
||||
if ($source !== '' && $target !== '') {
|
||||
$mapping[$source] = $target;
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
$text = trim((string) $raw);
|
||||
if ($text === '') {
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
$decoded = json_decode($text, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new PaymentException('支付方式映射配置不是合法 JSON', 40200, [
|
||||
'config_key' => 'type_mapping_json',
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($decoded as $key => $value) {
|
||||
$source = strtolower(trim((string) $key));
|
||||
$target = strtolower(trim((string) $value));
|
||||
if ($source !== '' && $target !== '') {
|
||||
$mapping[$source] = $target;
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $defaultMapping
|
||||
*/
|
||||
private function resolveUpstreamType(array $order, array $defaultMapping): string
|
||||
{
|
||||
$payTypeCode = strtolower(trim((string) ($order['pay_type_code'] ?? '')));
|
||||
if ($payTypeCode === '') {
|
||||
throw new PaymentException('订单缺少支付方式编码', 40200);
|
||||
}
|
||||
|
||||
$mapping = $this->resolveTypeMapping($defaultMapping);
|
||||
$upstreamType = strtolower(trim((string) ($mapping[$payTypeCode] ?? '')));
|
||||
if ($upstreamType === '') {
|
||||
throw new PaymentException('未配置上游支付方式映射', 40200, [
|
||||
'pay_type_code' => $payTypeCode,
|
||||
]);
|
||||
}
|
||||
|
||||
return $upstreamType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台内部支付单号,作为上游商户订单号。
|
||||
*/
|
||||
private function resolveOrderNo(array $order): string
|
||||
{
|
||||
$orderNo = trim((string) ($order['order_id'] ?? $order['pay_no'] ?? $order['out_trade_no'] ?? ''));
|
||||
if ($orderNo === '') {
|
||||
throw new PaymentException('订单缺少订单号', 40200);
|
||||
}
|
||||
|
||||
return $orderNo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付金额(分)。
|
||||
*/
|
||||
private function resolveAmount(array $order): int
|
||||
{
|
||||
$amount = (int) ($order['amount'] ?? $order['pay_amount'] ?? 0);
|
||||
if ($amount <= 0) {
|
||||
throw new PaymentException('订单金额不合法', 40200);
|
||||
}
|
||||
|
||||
return $amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单标题。
|
||||
*/
|
||||
private function resolveSubject(array $order): string
|
||||
{
|
||||
$subject = trim((string) ($order['subject'] ?? $order['body'] ?? ''));
|
||||
if ($subject === '') {
|
||||
throw new PaymentException('订单标题不能为空', 40200);
|
||||
}
|
||||
|
||||
if (function_exists('mb_strcut')) {
|
||||
return mb_strcut($subject, 0, 127, 'UTF-8');
|
||||
}
|
||||
|
||||
return substr($subject, 0, 127);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveExtraContext(array $order): array
|
||||
{
|
||||
$context = [];
|
||||
foreach ([$order['extra'] ?? null, $order['param'] ?? null] as $bag) {
|
||||
if (is_array($bag)) {
|
||||
$context = array_merge($context, $bag);
|
||||
foreach (['merchant', 'payment', 'source'] as $section) {
|
||||
if (isset($bag[$section]) && is_array($bag[$section])) {
|
||||
$context = array_merge($context, $bag[$section]);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_string($bag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = trim($bag);
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded = json_decode($text, true);
|
||||
if (is_array($decoded)) {
|
||||
$context = array_merge($context, $decoded);
|
||||
continue;
|
||||
}
|
||||
|
||||
parse_str($text, $parsed);
|
||||
if (is_array($parsed) && $parsed !== []) {
|
||||
$context = array_merge($context, $parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化备注透传字段。
|
||||
*/
|
||||
private function resolveParamValue(array $order): string
|
||||
{
|
||||
$context = $this->resolveExtraContext($order);
|
||||
$param = $context['param'] ?? null;
|
||||
if ($param === null || $param === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_scalar($param)) {
|
||||
return trim((string) $param);
|
||||
}
|
||||
|
||||
$json = json_encode($param, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $json !== false ? $json : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化客户端环境。
|
||||
*/
|
||||
private function resolveDevice(array $order, string $default = 'pc'): string
|
||||
{
|
||||
$device = strtolower(trim((string) ($order['_env'] ?? $order['device'] ?? '')));
|
||||
return $device !== '' ? $device : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 V2 上游 method。
|
||||
*/
|
||||
private function resolveV2Method(array $order): string
|
||||
{
|
||||
$context = $this->resolveExtraContext($order);
|
||||
$method = strtolower(trim((string) ($context['method'] ?? '')));
|
||||
$allowed = ['web', 'jump', 'jsapi', 'app', 'scan', 'applet'];
|
||||
if (in_array($method, $allowed, true)) {
|
||||
return $method;
|
||||
}
|
||||
|
||||
if (trim((string) ($context['auth_code'] ?? '')) !== '') {
|
||||
return 'scan';
|
||||
}
|
||||
|
||||
if (trim((string) ($context['sub_openid'] ?? '')) !== '') {
|
||||
return 'jsapi';
|
||||
}
|
||||
|
||||
return match ($this->resolveDevice($order)) {
|
||||
'wechat' => 'jsapi',
|
||||
'mobile', 'qq', 'alipay' => 'jump',
|
||||
default => 'web',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取回调入参。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveNotifyPayload(Request $request): array
|
||||
{
|
||||
$payload = array_merge((array) $request->get(), (array) $request->post());
|
||||
if ($payload !== []) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$all = $request->all();
|
||||
|
||||
return is_array($all) ? $all : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将分转换为元字符串。
|
||||
*/
|
||||
private function amountToMoney(int $amount): string
|
||||
{
|
||||
return FormatHelper::amount($amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{channel_order_no: string, channel_trade_no: string}
|
||||
*/
|
||||
private function resolveChannelNos(array $payload): array
|
||||
{
|
||||
$channelOrderNo = trim((string) ($payload['trade_no'] ?? $payload['transaction_id'] ?? ''));
|
||||
$channelTradeNo = trim((string) ($payload['api_trade_no'] ?? $payload['channel_trade_no'] ?? ''));
|
||||
|
||||
if ($channelOrderNo === '' && $channelTradeNo === '') {
|
||||
throw new PaymentException('上游返回缺少渠道订单号', 40200);
|
||||
}
|
||||
|
||||
if ($channelOrderNo === '') {
|
||||
$channelOrderNo = $channelTradeNo;
|
||||
}
|
||||
if ($channelTradeNo === '') {
|
||||
$channelTradeNo = $channelOrderNo;
|
||||
}
|
||||
|
||||
return [
|
||||
'channel_order_no' => $channelOrderNo,
|
||||
'channel_trade_no' => $channelTradeNo,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化回调支付状态。
|
||||
*/
|
||||
private function normalizeNotifyStatus(string $tradeStatus): string
|
||||
{
|
||||
$tradeStatus = strtoupper(trim($tradeStatus));
|
||||
if (in_array($tradeStatus, ['TRADE_SUCCESS', 'SUCCESS', 'PAY_SUCCESS', 'FINISHED'], true)) {
|
||||
return PaymentPluginStatusConstant::SUCCESS;
|
||||
}
|
||||
|
||||
if (in_array($tradeStatus, ['TRADE_FAIL', 'FAILED', 'TRADE_CLOSED', 'CLOSED', 'PAYERROR'], true)) {
|
||||
return PaymentPluginStatusConstant::FAILED;
|
||||
}
|
||||
|
||||
return PaymentPluginStatusConstant::PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成响应文本摘要。
|
||||
*/
|
||||
private function clipText(string $text, int $length = 240): string
|
||||
{
|
||||
$text = trim(preg_replace('/\s+/', ' ', $text) ?? $text);
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return strlen($text) <= $length ? $text : substr($text, 0, $length) . '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildMockPayResponse(array $payload, array $order): array
|
||||
{
|
||||
$seed = strtolower((string) ($payload['out_trade_no'] ?? $this->resolveOrderNo($order)));
|
||||
$channelOrderNo = 'V2ORD' . strtoupper(substr(md5($seed), 0, 16));
|
||||
$channelTradeNo = 'V2TRD' . strtoupper(substr(sha1($seed), 0, 16));
|
||||
|
||||
return $this->buildMockSignedResponse([
|
||||
'code' => 0,
|
||||
'msg' => 'success',
|
||||
'trade_no' => $channelOrderNo,
|
||||
'api_trade_no' => $channelTradeNo,
|
||||
'pay_type' => 'jump',
|
||||
'pay_info' => [
|
||||
'type' => 'jump',
|
||||
'payurl' => $this->resolveMockJumpUrl($channelTradeNo),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildMockQueryResponse(array $order): array
|
||||
{
|
||||
$channelOrderNo = trim((string) ($order['chan_order_no'] ?? ''));
|
||||
$channelTradeNo = trim((string) ($order['chan_trade_no'] ?? ''));
|
||||
if ($channelOrderNo === '' && $channelTradeNo === '') {
|
||||
$seed = strtolower($this->resolveOrderNo($order));
|
||||
$channelOrderNo = 'V2ORD' . strtoupper(substr(md5($seed), 0, 16));
|
||||
$channelTradeNo = 'V2TRD' . strtoupper(substr(sha1($seed), 0, 16));
|
||||
} elseif ($channelOrderNo === '') {
|
||||
$channelOrderNo = $channelTradeNo;
|
||||
} elseif ($channelTradeNo === '') {
|
||||
$channelTradeNo = $channelOrderNo;
|
||||
}
|
||||
|
||||
return $this->buildMockSignedResponse([
|
||||
'code' => 0,
|
||||
'msg' => 'success',
|
||||
'trade_no' => $channelOrderNo,
|
||||
'api_trade_no' => $channelTradeNo,
|
||||
'out_trade_no' => $this->resolveOrderNo($order),
|
||||
'status' => 1,
|
||||
'buyer' => 'MOCK_V2_BUYER',
|
||||
'param' => $this->resolveParamValue($order),
|
||||
'refundmoney' => '0.00',
|
||||
'endtime' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildMockCloseResponse(array $order): array
|
||||
{
|
||||
return $this->buildMockSignedResponse([
|
||||
'code' => 0,
|
||||
'msg' => 'success',
|
||||
'trade_no' => trim((string) ($order['chan_order_no'] ?? '')),
|
||||
'api_trade_no' => trim((string) ($order['chan_trade_no'] ?? '')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildMockRefundResponse(array $order): array
|
||||
{
|
||||
$seed = strtolower(trim((string) ($order['refund_no'] ?? $this->resolveOrderNo($order))));
|
||||
|
||||
return $this->buildMockSignedResponse([
|
||||
'code' => 0,
|
||||
'msg' => 'success',
|
||||
'refund_no' => 'V2REF' . strtoupper(substr(md5($seed), 0, 16)),
|
||||
'out_refund_no' => trim((string) ($order['refund_no'] ?? '')),
|
||||
'trade_no' => trim((string) ($order['chan_order_no'] ?? '')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildMockSignedResponse(array $payload): array
|
||||
{
|
||||
$payload['timestamp'] = (string) ($payload['timestamp'] ?? time());
|
||||
$payload['sign_type'] = AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA;
|
||||
|
||||
$signPayload = $payload;
|
||||
unset($signPayload['sign'], $signPayload['sign_type']);
|
||||
$payload['sign'] = $this->signerManager()->sign(
|
||||
$signPayload,
|
||||
AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
|
||||
$this->requireConfigValue('mock_platform_private_key', 'Mock 上游平台私钥')
|
||||
);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 mock 跳转地址。
|
||||
*/
|
||||
private function resolveMockJumpUrl(string $channelTradeNo): string
|
||||
{
|
||||
$baseUrl = trim((string) $this->getConfig('mock_jump_base_url', 'https://mock.epay.test/pay/v2'));
|
||||
if ($baseUrl === '') {
|
||||
$baseUrl = 'https://mock.epay.test/pay/v2';
|
||||
}
|
||||
|
||||
return rtrim($baseUrl, '/') . '?trade_no=' . rawurlencode($channelTradeNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizePayResponse(string $payType, mixed $payInfo): array
|
||||
{
|
||||
$payType = strtolower(trim($payType));
|
||||
$payload = is_array($payInfo) ? $payInfo : [];
|
||||
if (!is_array($payload)) {
|
||||
$payload = [];
|
||||
}
|
||||
|
||||
if (!is_array($payInfo)) {
|
||||
$text = trim((string) $payInfo);
|
||||
if ($text !== '') {
|
||||
$payload = match ($payType) {
|
||||
'jump' => ['payurl' => $text, 'redirect_url' => $text],
|
||||
'html' => ['html' => $text],
|
||||
'qrcode' => ['qrcode' => $text, 'qrcode_text' => $text],
|
||||
'urlscheme' => ['urlscheme' => $text, 'redirect_url' => $text],
|
||||
default => ['payload' => $text],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$payload['type'] = $payType !== '' ? $payType : (string) ($payload['type'] ?? '');
|
||||
|
||||
if ($payload['type'] === 'jump') {
|
||||
$jumpUrl = trim((string) ($payload['payurl'] ?? $payload['redirect_url'] ?? $payload['url'] ?? ''));
|
||||
if ($jumpUrl !== '') {
|
||||
$payload['payurl'] = $jumpUrl;
|
||||
$payload['redirect_url'] = $jumpUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if ($payload['type'] === 'qrcode') {
|
||||
$qrcode = trim((string) ($payload['qrcode'] ?? $payload['qrcode_text'] ?? $payload['qrcode_url'] ?? ''));
|
||||
if ($qrcode !== '') {
|
||||
$payload['qrcode'] = $qrcode;
|
||||
$payload['qrcode_text'] = $qrcode;
|
||||
}
|
||||
}
|
||||
|
||||
if ($payload['type'] === 'urlscheme') {
|
||||
$urlscheme = trim((string) ($payload['urlscheme'] ?? $payload['redirect_url'] ?? ''));
|
||||
if ($urlscheme !== '') {
|
||||
$payload['urlscheme'] = $urlscheme;
|
||||
$payload['redirect_url'] = $urlscheme;
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
547
app/common/payment/TemplatePayment.php
Normal file
547
app/common/payment/TemplatePayment.php
Normal file
@@ -0,0 +1,547 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\payment;
|
||||
|
||||
use app\common\base\BasePayment;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\common\interface\PaymentInterface;
|
||||
use app\common\interface\PayPluginInterface;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\exception\PaymentException;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 支付插件模板示例。
|
||||
*
|
||||
* 复制这个类时,通常只需要改下面几处:
|
||||
* - `paymentInfo` 里的 `code`、`name`、`pay_types`、`config_schema`
|
||||
* - `init()` 里的 SDK 初始化和配置装配
|
||||
* - `pay()` 里的真实第三方下单逻辑
|
||||
* - `query()`、`close()`、`refund()`、`notify()` 里的真实接口调用和验签逻辑
|
||||
*
|
||||
* 这是一个安全的起点模板,不依赖任何第三方 SDK。
|
||||
*/
|
||||
class TemplatePayment extends BasePayment implements PaymentInterface, PayPluginInterface
|
||||
{
|
||||
/**
|
||||
* 插件元信息。
|
||||
*
|
||||
* 复制后请优先修改 `code` 和 `pay_types`,避免和真实插件混淆。
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $paymentInfo = [
|
||||
'code' => 'template',
|
||||
'name' => '模板示例插件',
|
||||
'author' => 'MPAY',
|
||||
'version' => '1.0.0',
|
||||
'pay_types' => ['template'],
|
||||
'transfer_types' => [],
|
||||
'config_schema' => [
|
||||
[
|
||||
'type' => 'input',
|
||||
'field' => 'gateway_url',
|
||||
'title' => '网关地址',
|
||||
'value' => '',
|
||||
'props' => [
|
||||
'placeholder' => '请输入第三方网关地址',
|
||||
],
|
||||
'validate' => [
|
||||
[
|
||||
'required' => true,
|
||||
'message' => '网关地址不能为空',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'input',
|
||||
'field' => 'merchant_no',
|
||||
'title' => '商户号',
|
||||
'value' => '',
|
||||
'props' => [
|
||||
'placeholder' => '请输入商户号',
|
||||
],
|
||||
'validate' => [
|
||||
[
|
||||
'required' => true,
|
||||
'message' => '商户号不能为空',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'input',
|
||||
'field' => 'app_id',
|
||||
'title' => '应用ID',
|
||||
'value' => '',
|
||||
'props' => [
|
||||
'placeholder' => '请输入应用ID',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'textarea',
|
||||
'field' => 'app_secret',
|
||||
'title' => '签名密钥/私钥',
|
||||
'value' => '',
|
||||
'props' => [
|
||||
'placeholder' => '请输入签名密钥或私钥内容',
|
||||
'rows' => 4,
|
||||
],
|
||||
'validate' => [
|
||||
[
|
||||
'required' => true,
|
||||
'message' => '签名密钥不能为空',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'select',
|
||||
'field' => 'sign_type',
|
||||
'title' => '签名类型',
|
||||
'value' => AuthConstant::API_SIGN_NAME_MD5,
|
||||
'props' => [
|
||||
'placeholder' => '请选择签名类型',
|
||||
],
|
||||
'options' => [
|
||||
[
|
||||
'value' => AuthConstant::API_SIGN_NAME_MD5,
|
||||
'label' => AuthConstant::API_SIGN_NAME_MD5,
|
||||
],
|
||||
[
|
||||
'value' => 'RSA2',
|
||||
'label' => 'RSA2',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'select',
|
||||
'field' => 'default_product',
|
||||
'title' => '默认支付形态',
|
||||
'value' => 'html',
|
||||
'props' => [
|
||||
'placeholder' => '请选择默认支付形态',
|
||||
],
|
||||
'options' => [
|
||||
[
|
||||
'value' => 'html',
|
||||
'label' => '表单跳转',
|
||||
],
|
||||
[
|
||||
'value' => 'qrcode',
|
||||
'label' => '二维码',
|
||||
],
|
||||
[
|
||||
'value' => 'jump',
|
||||
'label' => '链接跳转',
|
||||
],
|
||||
[
|
||||
'value' => 'jsapi',
|
||||
'label' => 'JSAPI / 拉起参数',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* 初始化插件。
|
||||
*
|
||||
* 模板插件这里只做基础注入;真实插件可以在这里初始化 SDK、缓存配置或预处理证书。
|
||||
*
|
||||
* @param array $channelConfig 渠道配置
|
||||
* @return void
|
||||
*/
|
||||
public function init(array $channelConfig): void
|
||||
{
|
||||
parent::init($channelConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起支付下单。
|
||||
*
|
||||
* 这里保留的是“模板返回结构”,便于复制后直接替换成真实第三方调用。
|
||||
*
|
||||
* @param array $order 订单参数
|
||||
* @return array{
|
||||
* pay_product: string,
|
||||
* pay_action: string,
|
||||
* pay_params: array<string, mixed>,
|
||||
* chan_order_no: string,
|
||||
* chan_trade_no: string
|
||||
* }
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function pay(array $order): array
|
||||
{
|
||||
$orderNo = $this->requireOrderNo($order);
|
||||
$amount = $this->requireAmount($order);
|
||||
$subject = $this->requireSubject($order);
|
||||
$product = $this->resolveProduct($order);
|
||||
$payload = $this->buildRequestPayload($order, $orderNo, $amount, $subject);
|
||||
|
||||
return [
|
||||
'pay_product' => $product,
|
||||
'pay_action' => $product,
|
||||
'pay_params' => $this->buildPayParams($product, $payload),
|
||||
'chan_order_no' => $orderNo,
|
||||
'chan_trade_no' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询订单状态。
|
||||
*
|
||||
* 复制后请在这里替换成真实查单接口。
|
||||
*
|
||||
* @param array $order 订单参数
|
||||
* @return array
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function query(array $order): array
|
||||
{
|
||||
$this->throwTemplateTodo('查单');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭订单。
|
||||
*
|
||||
* 复制后请在这里替换成真实关单接口。
|
||||
*
|
||||
* @param array $order 订单参数
|
||||
* @return array
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function close(array $order): array
|
||||
{
|
||||
$this->throwTemplateTodo('关单');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请退款。
|
||||
*
|
||||
* 复制后请在这里替换成真实退款接口。
|
||||
*
|
||||
* @param array $order 订单参数
|
||||
* @return array
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function refund(array $order): array
|
||||
{
|
||||
$this->throwTemplateTodo('退款');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并验证支付回调通知。
|
||||
*
|
||||
* 复制后请在这里替换成真实回调验签和结果解析逻辑。
|
||||
* 验签失败直接抛出 `PaymentException`,验签通过后返回标准结果数组。
|
||||
* 如果渠道只返回一个唯一订单号,请同时填充 `channel_order_no` 和 `channel_trade_no`。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return array
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function notify(Request $request): array
|
||||
{
|
||||
$this->throwTemplateTodo('回调验签');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调成功响应。
|
||||
*
|
||||
* @return string|Response
|
||||
*/
|
||||
public function notifySuccess(): string|Response
|
||||
{
|
||||
return 'success';
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调失败响应。
|
||||
*
|
||||
* @return string|Response
|
||||
*/
|
||||
public function notifyFail(): string|Response
|
||||
{
|
||||
return 'fail';
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造第三方请求参数。
|
||||
*
|
||||
* 这里的字段只是示例,复制后按真实第三方接口自行增删。
|
||||
*
|
||||
* @param array $order 原始订单参数
|
||||
* @param string $orderNo 商户订单号
|
||||
* @param int $amount 金额(分)
|
||||
* @param string $subject 订单标题
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildRequestPayload(array $order, string $orderNo, int $amount, string $subject): array
|
||||
{
|
||||
$payload = [
|
||||
'merchant_no' => (string) $this->getConfig('merchant_no', ''),
|
||||
'app_id' => (string) $this->getConfig('app_id', ''),
|
||||
'pay_no' => (string) ($order['pay_no'] ?? $orderNo),
|
||||
'out_trade_no' => $orderNo,
|
||||
'biz_no' => (string) ($order['biz_no'] ?? ''),
|
||||
'trace_no' => (string) ($order['trace_no'] ?? ''),
|
||||
'channel_request_no' => (string) ($order['channel_request_no'] ?? ''),
|
||||
'amount' => $amount,
|
||||
'amount_yuan' => FormatHelper::amount($amount),
|
||||
'subject' => $subject,
|
||||
'body' => (string) ($order['body'] ?? ''),
|
||||
'notify_url' => (string) ($order['callback_url'] ?? ''),
|
||||
'return_url' => (string) ($order['return_url'] ?? ''),
|
||||
'device' => (string) ($order['_env'] ?? 'pc'),
|
||||
'extra' => $this->collectOrderContext($order),
|
||||
];
|
||||
|
||||
$signType = strtoupper((string) $this->getConfig('sign_type', AuthConstant::API_SIGN_NAME_MD5));
|
||||
$payload['sign_type'] = $signType !== '' ? $signType : AuthConstant::API_SIGN_NAME_MD5;
|
||||
$payload['sign'] = 'TODO';
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成支付页返回参数。
|
||||
*
|
||||
* @param string $product 支付形态
|
||||
* @param array<string, mixed> $payload 请求参数
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPayParams(string $product, array $payload): array
|
||||
{
|
||||
$gatewayUrl = (string) $this->getConfig('gateway_url', '');
|
||||
|
||||
return match ($product) {
|
||||
'qrcode' => [
|
||||
'type' => 'qrcode',
|
||||
'qrcode_text' => '请替换为真实二维码内容',
|
||||
'qrcode_url' => $gatewayUrl,
|
||||
'payload' => $payload,
|
||||
],
|
||||
'jump' => [
|
||||
'type' => 'jump',
|
||||
'redirect_url' => $gatewayUrl,
|
||||
'payload' => $payload,
|
||||
],
|
||||
'jsapi' => [
|
||||
'type' => 'jsapi',
|
||||
'order_string' => '请替换为真实调起参数',
|
||||
'payload' => $payload,
|
||||
],
|
||||
default => [
|
||||
'type' => 'html',
|
||||
'method' => 'POST',
|
||||
'action' => $gatewayUrl,
|
||||
'html' => $this->buildAutoSubmitForm($gatewayUrl, $payload),
|
||||
'payload' => $payload,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成自动提交表单。
|
||||
*
|
||||
* 这是很多表单跳转类插件最常见的返回方式,复制后可以直接改成真实字段。
|
||||
*
|
||||
* @param string $action 表单地址
|
||||
* @param array<string, mixed> $fields 表单字段
|
||||
* @return string HTML 片段
|
||||
*/
|
||||
private function buildAutoSubmitForm(string $action, array $fields): string
|
||||
{
|
||||
if ($action === '') {
|
||||
return '<!-- 请在模板插件中替换为真实表单地址 -->';
|
||||
}
|
||||
|
||||
$inputs = '';
|
||||
foreach ($fields as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$value = $encoded !== false ? $encoded : '';
|
||||
}
|
||||
|
||||
$key = htmlspecialchars((string) $key, ENT_QUOTES, 'UTF-8');
|
||||
$value = htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
$inputs .= sprintf('<input type="hidden" name="%s" value="%s">', $key, $value);
|
||||
}
|
||||
|
||||
$action = htmlspecialchars($action, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return sprintf(
|
||||
'<form id="template-pay-form" action="%s" method="post">%s</form><script>document.getElementById("template-pay-form").submit();</script>',
|
||||
$action,
|
||||
$inputs
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化订单上下文。
|
||||
*
|
||||
* 支付单拉起时,`extra` 使用 merchant/payment/presentation/plugin 分区。
|
||||
* 模板把常用分区展开到同一层,方便新插件读取 `param`、`method`、`auth_code` 等字段。
|
||||
*
|
||||
* @param array $order 原始订单参数
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function collectOrderContext(array $order): array
|
||||
{
|
||||
$context = $order;
|
||||
$extra = $this->normalizeBag($order['extra'] ?? null);
|
||||
$context = array_merge($context, $extra);
|
||||
foreach (['merchant', 'payment', 'source'] as $section) {
|
||||
if (isset($extra[$section]) && is_array($extra[$section])) {
|
||||
$context = array_merge($context, $extra[$section]);
|
||||
}
|
||||
}
|
||||
$context = array_merge($context, $this->normalizeBag($order['param'] ?? null));
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化数组、JSON 字符串或查询字符串。
|
||||
*
|
||||
* @param mixed $value 原始值
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeBag(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($value, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
parse_str($value, $parsed);
|
||||
if (is_array($parsed) && $parsed !== []) {
|
||||
return $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析默认支付形态。
|
||||
*
|
||||
* @param array $order 原始订单参数
|
||||
* @return string
|
||||
*/
|
||||
private function resolveProduct(array $order): string
|
||||
{
|
||||
$context = $this->collectOrderContext($order);
|
||||
$candidates = [
|
||||
$context['pay_product'] ?? null,
|
||||
$context['product'] ?? null,
|
||||
$context['pay_action'] ?? null,
|
||||
$context['action'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$product = $this->normalizeProductCode((string) $candidate);
|
||||
if ($product !== '') {
|
||||
return $product;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->normalizeProductCode((string) $this->getConfig('default_product', 'html')) ?: 'html';
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化支付形态标识。
|
||||
*
|
||||
* @param string $product 原始标识
|
||||
* @return string 标准化后的标识
|
||||
*/
|
||||
private function normalizeProductCode(string $product): string
|
||||
{
|
||||
$product = strtolower(trim($product));
|
||||
return in_array($product, ['html', 'qrcode', 'jump', 'jsapi'], true) ? $product : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并校验订单号。
|
||||
*
|
||||
* @param array $order 原始订单参数
|
||||
* @return string
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function requireOrderNo(array $order): string
|
||||
{
|
||||
$orderNo = trim((string) ($order['order_id'] ?? $order['pay_no'] ?? $order['out_trade_no'] ?? ''));
|
||||
if ($orderNo === '') {
|
||||
throw new PaymentException('模板插件下单缺少订单号', 40200);
|
||||
}
|
||||
|
||||
return $orderNo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并校验金额。
|
||||
*
|
||||
* @param array $order 原始订单参数
|
||||
* @return int
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function requireAmount(array $order): int
|
||||
{
|
||||
$amount = (int) ($order['amount'] ?? $order['pay_amount'] ?? $order['total_amount'] ?? 0);
|
||||
if ($amount <= 0) {
|
||||
throw new PaymentException('模板插件下单金额不合法', 40200);
|
||||
}
|
||||
|
||||
return $amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并校验订单标题。
|
||||
*
|
||||
* @param array $order 原始订单参数
|
||||
* @return string
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function requireSubject(array $order): string
|
||||
{
|
||||
$subject = trim((string) ($order['subject'] ?? $order['title'] ?? $order['body'] ?? ''));
|
||||
if ($subject === '') {
|
||||
throw new PaymentException('模板插件下单缺少标题', 40200);
|
||||
}
|
||||
|
||||
return $subject;
|
||||
}
|
||||
|
||||
/**
|
||||
* 抛出模板占位异常。
|
||||
*
|
||||
* @param string $action 当前动作
|
||||
* @return void
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function throwTemplateTodo(string $action): void
|
||||
{
|
||||
throw new PaymentException(sprintf('模板插件示例未实现%s逻辑,请复制后接入真实网关', $action), 40200);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use Firebase\JWT\ExpiredException;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Firebase\JWT\SignatureInvalidException;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\exception\AuthConfigException;
|
||||
use support\Redis;
|
||||
use Throwable;
|
||||
|
||||
@@ -29,6 +31,7 @@ class JwtTokenManager
|
||||
* @param array<string, mixed> $sessionData 会话数据
|
||||
* @param int|null $ttlSeconds 过期秒数
|
||||
* @return array{token:string,expires_in:int,jti:string,claims:array<string, mixed>,session:array<string, mixed>} 签发结果
|
||||
* @throws AuthConfigException
|
||||
*/
|
||||
public function issue(string $guard, array $claims, array $sessionData, ?int $ttlSeconds = null): array
|
||||
{
|
||||
@@ -47,7 +50,7 @@ class JwtTokenManager
|
||||
'guard' => $guard,
|
||||
], $claims);
|
||||
|
||||
$token = JWT::encode($payload, (string) $guardConfig['secret'], 'HS256');
|
||||
$token = JWT::encode($payload, (string) $guardConfig['secret'], AuthConstant::JWT_ALGORITHM_HS256);
|
||||
|
||||
$session = array_merge($sessionData, [
|
||||
'guard' => $guard,
|
||||
@@ -80,6 +83,7 @@ class JwtTokenManager
|
||||
* @param string $ip 最近访问 IP
|
||||
* @param string $userAgent 用户Agent
|
||||
* @return array{claims:array<string, mixed>,session:array<string, mixed>}|null 验证结果
|
||||
* @throws AuthConfigException
|
||||
*/
|
||||
public function verify(string $guard, string $token, string $ip = '', string $userAgent = ''): ?array
|
||||
{
|
||||
@@ -126,6 +130,7 @@ class JwtTokenManager
|
||||
* @param string $guard 登录域
|
||||
* @param string $token JWT 字符串
|
||||
* @return bool 是否已撤销
|
||||
* @throws AuthConfigException
|
||||
*/
|
||||
public function revoke(string $guard, string $token): bool
|
||||
{
|
||||
@@ -150,6 +155,7 @@ class JwtTokenManager
|
||||
* @param string $guard 登录域
|
||||
* @param string $jti 会话标识
|
||||
* @return bool 是否已撤销
|
||||
* @throws AuthConfigException
|
||||
*/
|
||||
public function revokeByJti(string $guard, string $jti): bool
|
||||
{
|
||||
@@ -168,6 +174,7 @@ class JwtTokenManager
|
||||
* @param string $guard 登录域
|
||||
* @param string $jti 会话标识
|
||||
* @return array<string, mixed>|null 会话数据
|
||||
* @throws AuthConfigException
|
||||
*/
|
||||
public function session(string $guard, string $jti): ?array
|
||||
{
|
||||
@@ -188,6 +195,7 @@ class JwtTokenManager
|
||||
* @param string $guard 登录域
|
||||
* @param string $token JWT 字符串
|
||||
* @return array<string, mixed>|null JWT 载荷
|
||||
* @throws AuthConfigException
|
||||
*/
|
||||
protected function decode(string $guard, string $token): ?array
|
||||
{
|
||||
@@ -196,7 +204,7 @@ class JwtTokenManager
|
||||
|
||||
try {
|
||||
JWT::$leeway = (int) config('auth.leeway', 30);
|
||||
$payload = JWT::decode($token, new Key((string) $guardConfig['secret'], 'HS256'));
|
||||
$payload = JWT::decode($token, new Key((string) $guardConfig['secret'], AuthConstant::JWT_ALGORITHM_HS256));
|
||||
} catch (ExpiredException|SignatureInvalidException|Throwable) {
|
||||
return null;
|
||||
}
|
||||
@@ -221,6 +229,7 @@ class JwtTokenManager
|
||||
* @param array<string, mixed> $session 会话数据
|
||||
* @param int $ttlSeconds 过期秒数
|
||||
* @return void
|
||||
* @throws AuthConfigException
|
||||
*/
|
||||
protected function storeSession(string $guard, string $jti, array $session, int $ttlSeconds): void
|
||||
{
|
||||
@@ -239,6 +248,7 @@ class JwtTokenManager
|
||||
* @param string $guard 登录域
|
||||
* @param string $jti 会话标识
|
||||
* @return string Redis 会话键
|
||||
* @throws AuthConfigException
|
||||
*/
|
||||
protected function sessionKey(string $guard, string $jti): string
|
||||
{
|
||||
@@ -250,13 +260,15 @@ class JwtTokenManager
|
||||
*
|
||||
* @param string $guard 登录域
|
||||
* @return array<string, mixed> 认证配置
|
||||
* @throws \InvalidArgumentException
|
||||
* @throws AuthConfigException
|
||||
*/
|
||||
protected function guardConfig(string $guard): array
|
||||
{
|
||||
$guards = (array) config('auth.guards', []);
|
||||
if (!isset($guards[$guard])) {
|
||||
throw new \InvalidArgumentException("Unknown auth guard: {$guard}");
|
||||
throw new AuthConfigException("未知认证域:{$guard}", [
|
||||
'guard' => $guard,
|
||||
]);
|
||||
}
|
||||
|
||||
return $guards[$guard];
|
||||
@@ -268,7 +280,7 @@ class JwtTokenManager
|
||||
* @param string $guard 登录域
|
||||
* @param string $secret 密钥
|
||||
* @return void
|
||||
* @throws RuntimeException
|
||||
* @throws AuthConfigException
|
||||
*/
|
||||
protected function assertHmacSecretLength(string $guard, string $secret): void
|
||||
{
|
||||
@@ -277,17 +289,19 @@ class JwtTokenManager
|
||||
}
|
||||
|
||||
$envNames = match ($guard) {
|
||||
'admin' => 'AUTH_ADMIN_JWT_SECRET or AUTH_JWT_SECRET',
|
||||
'merchant' => 'AUTH_MERCHANT_JWT_SECRET or AUTH_JWT_SECRET',
|
||||
default => 'the configured JWT secret',
|
||||
AuthConstant::GUARD_ADMIN => 'AUTH_ADMIN_JWT_SECRET 或 AUTH_JWT_SECRET',
|
||||
AuthConstant::GUARD_MERCHANT => 'AUTH_MERCHANT_JWT_SECRET 或 AUTH_JWT_SECRET',
|
||||
default => '当前配置的 JWT 密钥',
|
||||
};
|
||||
|
||||
throw new \RuntimeException(sprintf(
|
||||
'JWT secret for guard "%s" is too short for HS256. Please set %s to at least 32 ASCII characters.',
|
||||
throw new AuthConfigException(sprintf(
|
||||
'认证域 "%s" 的 JWT 密钥长度不足,HS256 至少需要 32 个 ASCII 字符,请将 %s 配置为至少 32 个字符。',
|
||||
$guard,
|
||||
$envNames
|
||||
));
|
||||
), [
|
||||
'guard' => $guard,
|
||||
'env_names' => $envNames,
|
||||
'secret_length' => strlen($secret),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
101
app/common/util/RsaKeyPairGenerator.php
Normal file
101
app/common/util/RsaKeyPairGenerator.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\util;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* RSA 密钥对生成器。
|
||||
*
|
||||
* 统一用于后台自动生成商户 RSA 公私钥对,避免各处重复实现。
|
||||
*/
|
||||
final class RsaKeyPairGenerator
|
||||
{
|
||||
/**
|
||||
* 生成 RSA 密钥对。
|
||||
*
|
||||
* @param int $bits 密钥长度
|
||||
* @return array{private_key: string, public_key: string}
|
||||
*/
|
||||
public static function generate(int $bits = 2048): array
|
||||
{
|
||||
while (openssl_error_string()) {
|
||||
}
|
||||
|
||||
$configPath = self::resolveOpenSslConfigPath();
|
||||
if ($configPath === '') {
|
||||
throw new RuntimeException('生成 RSA 密钥对失败,未找到可用的 openssl.cnf 配置文件');
|
||||
}
|
||||
|
||||
$resource = openssl_pkey_new([
|
||||
'private_key_bits' => max(1024, $bits),
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
'config' => $configPath,
|
||||
]);
|
||||
if ($resource === false) {
|
||||
throw new RuntimeException('生成 RSA 密钥对失败:' . self::collectOpenSslErrors());
|
||||
}
|
||||
|
||||
$privateKey = '';
|
||||
if (!openssl_pkey_export($resource, $privateKey, null, ['config' => $configPath]) || trim($privateKey) === '') {
|
||||
throw new RuntimeException('导出 RSA 私钥失败:' . self::collectOpenSslErrors());
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
$publicKey = trim((string) ($details['key'] ?? ''));
|
||||
if ($publicKey === '') {
|
||||
throw new RuntimeException('导出 RSA 公钥失败');
|
||||
}
|
||||
|
||||
return [
|
||||
'private_key' => trim($privateKey),
|
||||
'public_key' => $publicKey,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找可用的 OpenSSL 配置文件。
|
||||
*
|
||||
* @return string 配置文件路径
|
||||
*/
|
||||
private static function resolveOpenSslConfigPath(): string
|
||||
{
|
||||
$candidates = [];
|
||||
|
||||
$envConfig = trim((string) getenv('OPENSSL_CONF'));
|
||||
if ($envConfig !== '') {
|
||||
$candidates[] = $envConfig;
|
||||
}
|
||||
|
||||
$baseDir = dirname(PHP_BINARY);
|
||||
$candidates[] = $baseDir . DIRECTORY_SEPARATOR . 'extras' . DIRECTORY_SEPARATOR . 'ssl' . DIRECTORY_SEPARATOR . 'openssl.cnf';
|
||||
$candidates[] = $baseDir . DIRECTORY_SEPARATOR . 'openssl.cnf';
|
||||
$candidates[] = dirname($baseDir) . DIRECTORY_SEPARATOR . 'Apache2.4.39' . DIRECTORY_SEPARATOR . 'conf' . DIRECTORY_SEPARATOR . 'openssl.cnf';
|
||||
$candidates[] = 'C:\\Program Files\\Git\\mingw64\\etc\\ssl\\openssl.cnf';
|
||||
$candidates[] = 'C:\\Program Files\\Git\\usr\\ssl\\openssl.cnf';
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$candidate = trim((string) $candidate);
|
||||
if ($candidate !== '' && is_file($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集当前 OpenSSL 错误栈。
|
||||
*
|
||||
* @return string 错误信息
|
||||
*/
|
||||
private static function collectOpenSslErrors(): string
|
||||
{
|
||||
$messages = [];
|
||||
while ($message = openssl_error_string()) {
|
||||
$messages[] = $message;
|
||||
}
|
||||
|
||||
return $messages ? implode(' | ', $messages) : 'unknown error';
|
||||
}
|
||||
}
|
||||
30
app/exception/AuthConfigException.php
Normal file
30
app/exception/AuthConfigException.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace app\exception;
|
||||
|
||||
use Webman\Exception\BusinessException;
|
||||
|
||||
/**
|
||||
* 认证配置异常。
|
||||
*
|
||||
* 用于认证域不存在、JWT 密钥长度不足和认证配置不合法等场景。
|
||||
*/
|
||||
class AuthConfigException extends BusinessException
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param string $message message
|
||||
* @param array $data 数据
|
||||
* @param int $bizCode 业务Code
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(string $message = '认证配置错误', array $data = [], int $bizCode = 50001)
|
||||
{
|
||||
parent::__construct($message, $bizCode);
|
||||
|
||||
if (!empty($data)) {
|
||||
$this->data($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
app/exception/CommandException.php
Normal file
47
app/exception/CommandException.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace app\exception;
|
||||
|
||||
use Webman\Exception\BusinessException;
|
||||
|
||||
/**
|
||||
* 命令执行异常。
|
||||
*
|
||||
* 用于命令行工具、测试命令和脚本解析失败等场景。
|
||||
*/
|
||||
class CommandException extends BusinessException
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param string $message 异常消息
|
||||
* @param int $bizCode 业务码
|
||||
* @param \Throwable|null $previous 前一个异常
|
||||
* @param array<string, mixed> $data 附加数据
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(string $message = '命令执行失败', int $bizCode = 50002, ?\Throwable $previous = null, array $data = [])
|
||||
{
|
||||
parent::__construct($message, $bizCode, $previous);
|
||||
|
||||
if ($previous !== null) {
|
||||
$data = array_merge([
|
||||
'previous_exception' => $previous::class,
|
||||
'previous_message' => $previous->getMessage(),
|
||||
], $data);
|
||||
}
|
||||
|
||||
$this->data($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取附加数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getData(): array
|
||||
{
|
||||
$data = $this->data();
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
}
|
||||
30
app/exception/UnauthorizedException.php
Normal file
30
app/exception/UnauthorizedException.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace app\exception;
|
||||
|
||||
use Webman\Exception\BusinessException;
|
||||
|
||||
/**
|
||||
* 鉴权异常。
|
||||
*
|
||||
* 用于 token 缺失、token 无效和登录态失效等场景,统一业务码为 40100。
|
||||
*/
|
||||
class UnauthorizedException extends BusinessException
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param string $message message
|
||||
* @param array $data 数据
|
||||
* @param int $bizCode 业务Code
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(string $message = '未授权', array $data = [], int $bizCode = 401)
|
||||
{
|
||||
parent::__construct($message, $bizCode);
|
||||
|
||||
if (!empty($data)) {
|
||||
$this->data($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\http\admin\controller\merchant;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\http\admin\validation\MerchantApiCredentialValidator;
|
||||
use app\http\admin\validation\MerchantValidator;
|
||||
use app\service\merchant\MerchantService;
|
||||
use support\Request;
|
||||
@@ -136,8 +137,9 @@ class MerchantController extends BaseController
|
||||
public function issueCredential(Request $request, string $id): Response
|
||||
{
|
||||
$merchantId = (int) $id;
|
||||
$data = $this->validated($request->all(), MerchantApiCredentialValidator::class, 'issueCredential');
|
||||
|
||||
return $this->success($this->merchantService->issueCredential($merchantId));
|
||||
return $this->success($this->merchantService->issueCredential($merchantId, $data));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\admin\controller\ops;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\http\admin\validation\MerchantNotifyTaskValidator;
|
||||
use app\service\ops\log\MerchantNotifyTaskService;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 商户通知任务控制器。
|
||||
*
|
||||
* @property MerchantNotifyTaskService $merchantNotifyTaskService 商户通知任务服务
|
||||
*/
|
||||
class MerchantNotifyTaskController extends BaseController
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param MerchantNotifyTaskService $merchantNotifyTaskService 商户通知任务服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantNotifyTaskService $merchantNotifyTaskService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户通知任务列表。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$data = $this->validated($request->all(), MerchantNotifyTaskValidator::class, 'index');
|
||||
|
||||
return $this->page(
|
||||
$this->merchantNotifyTaskService->paginate(
|
||||
$data,
|
||||
(int) ($data['page'] ?? 1),
|
||||
(int) ($data['page_size'] ?? 10)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户通知任务详情。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param string $notifyNo 通知号
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function show(Request $request, string $notifyNo): Response
|
||||
{
|
||||
$data = $this->validated(['notify_no' => $notifyNo], MerchantNotifyTaskValidator::class, 'show');
|
||||
$task = $this->merchantNotifyTaskService->findByNotifyNo((string) $data['notify_no']);
|
||||
|
||||
if (!$task) {
|
||||
return $this->fail('商户通知任务不存在', 404);
|
||||
}
|
||||
|
||||
return $this->success($task);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动重试商户通知任务。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param string $notifyNo 通知号
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function retry(Request $request, string $notifyNo): Response
|
||||
{
|
||||
$data = $this->validated(['notify_no' => $notifyNo], MerchantNotifyTaskValidator::class, 'retry');
|
||||
$task = $this->merchantNotifyTaskService->retry((string) $data['notify_no']);
|
||||
|
||||
if (!$task) {
|
||||
return $this->fail('商户通知任务不存在', 404);
|
||||
}
|
||||
|
||||
return $this->success($task, '商户通知任务已执行重试');
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,24 @@ class AuthController extends BaseController
|
||||
(string) $this->requestAttribute($request, 'auth.admin_username', '')
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改当前登录管理员密码。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function changePassword(Request $request): Response
|
||||
{
|
||||
$adminId = $this->currentAdminId($request);
|
||||
if ($adminId <= 0) {
|
||||
return $this->fail('未获取到当前管理员信息', 401);
|
||||
}
|
||||
|
||||
$data = $this->validated($request->all(), AuthValidator::class, 'changePassword');
|
||||
|
||||
return $this->success($this->adminUserService->changePassword($adminId, $data));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use support\Response;
|
||||
/**
|
||||
* 支付订单管理控制器。
|
||||
*
|
||||
* 当前先提供列表查询,后续可以继续扩展支付单详情、关闭、重试等管理操作。
|
||||
* 当前提供列表查询和详情查看,便于后台直接排查支付链路。
|
||||
*
|
||||
* @property PayOrderService $payOrderService 支付订单服务
|
||||
*/
|
||||
@@ -42,6 +42,24 @@ class PayOrderController extends BaseController
|
||||
|
||||
return $this->success($this->payOrderService->paginate($data, $page, $pageSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询支付订单详情。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param string $payNo 支付单号
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function show(Request $request, string $payNo): Response
|
||||
{
|
||||
$this->validated(
|
||||
array_merge($request->all(), ['pay_no' => $payNo]),
|
||||
PayOrderValidator::class,
|
||||
'show'
|
||||
);
|
||||
|
||||
return $this->success($this->payOrderService->detail($payNo));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\http\admin\middleware;
|
||||
|
||||
use app\exception\UnauthorizedException;
|
||||
use app\service\system\access\AdminAuthService;
|
||||
use support\Context;
|
||||
use Webman\Http\Request;
|
||||
@@ -34,6 +35,7 @@ class AdminAuthMiddleware implements MiddlewareInterface
|
||||
* @param Request $request 请求对象
|
||||
* @param callable $handler handler
|
||||
* @return Response 响应对象
|
||||
* @throws UnauthorizedException
|
||||
*/
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
@@ -42,11 +44,7 @@ class AdminAuthMiddleware implements MiddlewareInterface
|
||||
|
||||
if ($token === '') {
|
||||
if ((int) env('AUTH_MIDDLEWARE_STRICT', 1) === 1) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'msg' => 'admin unauthorized',
|
||||
'data' => null,
|
||||
]);
|
||||
throw new UnauthorizedException('管理员未授权');
|
||||
}
|
||||
} else {
|
||||
$admin = $this->adminAuthService->authenticateToken(
|
||||
@@ -55,11 +53,7 @@ class AdminAuthMiddleware implements MiddlewareInterface
|
||||
$request->header('user-agent', '')
|
||||
);
|
||||
if (!$admin) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'msg' => 'admin unauthorized',
|
||||
'data' => null,
|
||||
]);
|
||||
throw new UnauthorizedException('管理员未授权');
|
||||
}
|
||||
|
||||
Context::set('auth.admin_id', (int) $admin->id);
|
||||
|
||||
@@ -19,6 +19,8 @@ class AuthValidator extends Validator
|
||||
protected array $rules = [
|
||||
'username' => 'required|string|min:1|max:32',
|
||||
'password' => 'required|string|min:6|max:100',
|
||||
'current_password' => 'required|string|min:6|max:100',
|
||||
'password_confirm' => 'required|string|min:6|max:100|same:password',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -29,6 +31,8 @@ class AuthValidator extends Validator
|
||||
protected array $attributes = [
|
||||
'username' => '用户名',
|
||||
'password' => '密码',
|
||||
'current_password' => '旧密码',
|
||||
'password_confirm' => '确认密码',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -38,6 +42,7 @@ class AuthValidator extends Validator
|
||||
*/
|
||||
protected array $scenes = [
|
||||
'login' => ['username', 'password'],
|
||||
'changePassword' => ['current_password', 'password', 'password_confirm'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,11 @@ class MerchantApiCredentialValidator extends Validator
|
||||
'id' => 'sometimes|integer|min:1',
|
||||
'keyword' => 'sometimes|string|max:128',
|
||||
'merchant_id' => 'sometimes|integer|min:1|exists:ma_merchant,id',
|
||||
'sign_type' => 'sometimes|integer|in:0',
|
||||
'sign_type' => 'sometimes|integer|in:0,1',
|
||||
'rotate_v1' => 'sometimes|integer|in:0,1',
|
||||
'rotate_v2' => 'sometimes|integer|in:0,1',
|
||||
'api_key' => 'nullable|string|max:128',
|
||||
'merchant_public_key' => 'nullable|string|max:65535',
|
||||
'status' => 'sometimes|integer|in:0,1',
|
||||
'page' => 'sometimes|integer|min:1',
|
||||
'page_size' => 'sometimes|integer|min:1|max:100',
|
||||
@@ -35,7 +38,10 @@ class MerchantApiCredentialValidator extends Validator
|
||||
'keyword' => '关键词',
|
||||
'merchant_id' => '所属商户',
|
||||
'sign_type' => '签名类型',
|
||||
'rotate_v1' => '是否重置 V1',
|
||||
'rotate_v2' => '是否重置 V2',
|
||||
'api_key' => '接口凭证值',
|
||||
'merchant_public_key' => '商户公钥',
|
||||
'status' => '接口凭证状态',
|
||||
'page' => '页码',
|
||||
'page_size' => '每页条数',
|
||||
@@ -48,10 +54,11 @@ class MerchantApiCredentialValidator extends Validator
|
||||
*/
|
||||
protected array $scenes = [
|
||||
'index' => ['keyword', 'merchant_id', 'status', 'page', 'page_size'],
|
||||
'store' => ['merchant_id', 'sign_type', 'api_key', 'status'],
|
||||
'update' => ['id', 'sign_type', 'api_key', 'status'],
|
||||
'store' => ['merchant_id', 'sign_type', 'api_key', 'merchant_public_key', 'status'],
|
||||
'update' => ['id', 'sign_type', 'api_key', 'merchant_public_key', 'status'],
|
||||
'show' => ['id'],
|
||||
'destroy' => ['id'],
|
||||
'issueCredential' => ['rotate_v1', 'rotate_v2', 'sign_type', 'status'],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -63,7 +70,8 @@ class MerchantApiCredentialValidator extends Validator
|
||||
{
|
||||
return $this->appendRules([
|
||||
'merchant_id' => 'required|integer|min:1|exists:ma_merchant,id',
|
||||
'sign_type' => 'required|integer|in:0',
|
||||
'sign_type' => 'required|integer|in:0,1',
|
||||
'merchant_public_key' => 'nullable|string|max:65535',
|
||||
'status' => 'required|integer|in:0,1',
|
||||
]);
|
||||
}
|
||||
@@ -77,8 +85,25 @@ class MerchantApiCredentialValidator extends Validator
|
||||
{
|
||||
return $this->appendRules([
|
||||
'id' => 'required|integer|min:1',
|
||||
'sign_type' => 'required|integer|in:0',
|
||||
'status' => 'required|integer|in:0,1',
|
||||
'sign_type' => 'sometimes|integer|in:0,1',
|
||||
'api_key' => 'nullable|string|max:128',
|
||||
'merchant_public_key' => 'nullable|string|max:65535',
|
||||
'status' => 'sometimes|integer|in:0,1',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置生成接口凭证场景规则。
|
||||
*
|
||||
* @return static 校验器实例
|
||||
*/
|
||||
public function sceneIssueCredential(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'rotate_v1' => 'sometimes|integer|in:0,1',
|
||||
'rotate_v2' => 'sometimes|integer|in:0,1',
|
||||
'sign_type' => 'sometimes|integer|in:0,1',
|
||||
'status' => 'sometimes|integer|in:0,1',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
72
app/http/admin/validation/MerchantNotifyTaskValidator.php
Normal file
72
app/http/admin/validation/MerchantNotifyTaskValidator.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\admin\validation;
|
||||
|
||||
use support\validation\Validator;
|
||||
|
||||
/**
|
||||
* 商户通知任务参数校验器。
|
||||
*/
|
||||
class MerchantNotifyTaskValidator extends Validator
|
||||
{
|
||||
/**
|
||||
* 校验规则。
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $rules = [
|
||||
'notify_no' => 'sometimes|string|max:64',
|
||||
'keyword' => 'sometimes|string|max:128',
|
||||
'merchant_id' => 'sometimes|integer|min:1',
|
||||
'status' => 'sometimes|integer|in:0,1,2',
|
||||
'page' => 'sometimes|integer|min:1',
|
||||
'page_size' => 'sometimes|integer|min:1|max:100',
|
||||
];
|
||||
|
||||
/**
|
||||
* 字段别名。
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $attributes = [
|
||||
'notify_no' => '通知号',
|
||||
'keyword' => '关键词',
|
||||
'merchant_id' => '所属商户',
|
||||
'status' => '任务状态',
|
||||
'page' => '页码',
|
||||
'page_size' => '每页条数',
|
||||
];
|
||||
|
||||
/**
|
||||
* 校验场景。
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $scenes = [
|
||||
'index' => ['keyword', 'merchant_id', 'status', 'page', 'page_size'],
|
||||
'show' => ['notify_no'],
|
||||
'retry' => ['notify_no'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 配置详情场景规则。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneShow(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'notify_no' => 'required|string|max:64',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置重试场景规则。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneRetry(): static
|
||||
{
|
||||
return $this->sceneShow();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ class PayOrderValidator extends Validator
|
||||
* @var array
|
||||
*/
|
||||
protected array $rules = [
|
||||
'pay_no' => 'sometimes|string|max:32',
|
||||
'keyword' => 'sometimes|string|max:128',
|
||||
'merchant_id' => 'sometimes|integer|min:1',
|
||||
'pay_type_id' => 'sometimes|integer|min:1',
|
||||
@@ -33,6 +34,7 @@ class PayOrderValidator extends Validator
|
||||
* @var array
|
||||
*/
|
||||
protected array $attributes = [
|
||||
'pay_no' => '支付单号',
|
||||
'keyword' => '关键字',
|
||||
'merchant_id' => '商户ID',
|
||||
'pay_type_id' => '支付方式',
|
||||
@@ -50,6 +52,6 @@ class PayOrderValidator extends Validator
|
||||
*/
|
||||
protected array $scenes = [
|
||||
'index' => ['keyword', 'merchant_id', 'pay_type_id', 'status', 'channel_mode', 'callback_status', 'page', 'page_size'],
|
||||
'show' => ['pay_no'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class PaymentPluginConfValidator extends Validator
|
||||
protected array $rules = [
|
||||
'id' => 'sometimes|integer|min:1',
|
||||
'keyword' => 'sometimes|string|max:128',
|
||||
'merchant_id' => 'sometimes|integer|min:0',
|
||||
'plugin_code' => 'sometimes|string|alpha_dash|min:2|max:32',
|
||||
'config' => 'nullable|array',
|
||||
'settlement_cycle_type' => 'sometimes|integer|in:0,1,2,3,4',
|
||||
@@ -37,6 +38,7 @@ class PaymentPluginConfValidator extends Validator
|
||||
protected array $attributes = [
|
||||
'id' => '配置ID',
|
||||
'keyword' => '关键字',
|
||||
'merchant_id' => '所属商户',
|
||||
'plugin_code' => '插件编码',
|
||||
'config' => '插件配置',
|
||||
'settlement_cycle_type' => '结算周期',
|
||||
@@ -53,9 +55,9 @@ class PaymentPluginConfValidator extends Validator
|
||||
* @var array
|
||||
*/
|
||||
protected array $scenes = [
|
||||
'index' => ['keyword', 'plugin_code', 'page', 'page_size'],
|
||||
'store' => ['plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'],
|
||||
'update' => ['id', 'plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'],
|
||||
'index' => ['keyword', 'merchant_id', 'plugin_code', 'page', 'page_size'],
|
||||
'store' => ['merchant_id', 'plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'],
|
||||
'update' => ['id', 'merchant_id', 'plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'],
|
||||
'show' => ['id'],
|
||||
'destroy' => ['id'],
|
||||
'options' => ['plugin_code'],
|
||||
@@ -87,4 +89,3 @@ class PaymentPluginConfValidator extends Validator
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class PaymentPluginValidator extends Validator
|
||||
protected array $rules = [
|
||||
'code' => 'sometimes|string|alpha_dash|min:2|max:32',
|
||||
'status' => 'sometimes|integer|in:0,1',
|
||||
'allow_merchant' => 'sometimes|integer|in:0,1',
|
||||
'remark' => 'nullable|string|max:500',
|
||||
'keyword' => 'sometimes|string|max:128',
|
||||
'name' => 'sometimes|string|max:50',
|
||||
@@ -37,6 +38,7 @@ class PaymentPluginValidator extends Validator
|
||||
'code' => '插件编码',
|
||||
'name' => '插件名称',
|
||||
'status' => '插件状态',
|
||||
'allow_merchant' => '商户端可用',
|
||||
'remark' => '备注',
|
||||
'keyword' => '关键字',
|
||||
'page' => '页码',
|
||||
@@ -52,10 +54,9 @@ class PaymentPluginValidator extends Validator
|
||||
*/
|
||||
protected array $scenes = [
|
||||
'index' => ['keyword', 'code', 'name', 'status', 'page', 'page_size'],
|
||||
'update' => ['code', 'status', 'remark'],
|
||||
'update' => ['code', 'status', 'allow_merchant', 'remark'],
|
||||
'updateStatus' => ['code', 'status'],
|
||||
'show' => ['code'],
|
||||
'selectOptions' => ['keyword', 'page', 'page_size', 'pay_type_code', 'ids'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
67
app/http/api/controller/cashier/CashierController.php
Normal file
67
app/http/api/controller/cashier/CashierController.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\api\controller\cashier;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\http\api\validation\CashierValidator;
|
||||
use app\service\payment\cashier\CashierService;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 收银台控制器。
|
||||
*
|
||||
* 提供收银台上下文查询和支付确认入口。
|
||||
*/
|
||||
class CashierController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected CashierService $cashierService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询收银台上下文。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function context(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), CashierValidator::class, 'context');
|
||||
|
||||
return $this->success(
|
||||
$this->cashierService->context((string) ($payload['biz_no'] ?? ''))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认收银台支付方式。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function confirm(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), CashierValidator::class, 'confirm');
|
||||
|
||||
return $this->success(
|
||||
$this->cashierService->confirm($payload, $request)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询支付页详情。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function payOrder(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), CashierValidator::class, 'pay_order');
|
||||
|
||||
return $this->success(
|
||||
$this->cashierService->payOrderDetail((string) ($payload['pay_no'] ?? ''))
|
||||
);
|
||||
}
|
||||
}
|
||||
87
app/http/api/controller/epay/EpayV1Controller.php
Normal file
87
app/http/api/controller/epay/EpayV1Controller.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\api\controller\epay;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\service\payment\epay\EpayV1ProtocolService;
|
||||
use app\http\api\validation\EpayV1Validator;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* ePay V1 控制器。
|
||||
*
|
||||
* 负责承接旧版页面跳转、API 支付与旧接口兼容查询。
|
||||
*/
|
||||
class EpayV1Controller extends BaseController
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param EpayV1ProtocolService $epayV1ProtocolService V1 协议服务
|
||||
*/
|
||||
public function __construct(
|
||||
protected EpayV1ProtocolService $epayV1ProtocolService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面跳转支付入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function submit(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), EpayV1Validator::class, 'submit');
|
||||
return $this->epayV1ProtocolService->submit($payload, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 支付入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function mapi(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), EpayV1Validator::class, 'mapi');
|
||||
return json($this->epayV1ProtocolService->mapi($payload, $request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧版兼容 API 入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function api(Request $request): Response
|
||||
{
|
||||
$payload = $request->all();
|
||||
$scene = $this->resolveApiScene((string) ($payload['act'] ?? ''));
|
||||
if ($scene === null) {
|
||||
return json(['code' => 0, 'msg' => '不支持的操作类型']);
|
||||
}
|
||||
|
||||
$payload = $this->validated($payload, EpayV1Validator::class, $scene);
|
||||
return json($this->epayV1ProtocolService->api($payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射旧版 `act` 到验证场景。
|
||||
*
|
||||
* @param string $act 接口动作
|
||||
* @return string|null 验证场景
|
||||
*/
|
||||
private function resolveApiScene(string $act): ?string
|
||||
{
|
||||
return match (strtolower(trim($act))) {
|
||||
'query' => 'api_query',
|
||||
'settle' => 'api_settle',
|
||||
'order' => 'api_order',
|
||||
'orders' => 'api_orders',
|
||||
'refund' => 'api_refund',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
173
app/http/api/controller/epay/EpayV2Controller.php
Normal file
173
app/http/api/controller/epay/EpayV2Controller.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\api\controller\epay;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\http\api\validation\EpayV2Validator;
|
||||
use app\service\payment\epay\EpayV2ProtocolService;
|
||||
use app\service\payment\order\PayOrderService;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* ePay V2 控制器。
|
||||
*
|
||||
* 负责承接新版支付、查询、退款、商户与转账接口。
|
||||
*/
|
||||
class EpayV2Controller extends BaseController
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param EpayV2ProtocolService $epayV2ProtocolService V2 协议服务
|
||||
*/
|
||||
public function __construct(
|
||||
protected EpayV2ProtocolService $epayV2ProtocolService,
|
||||
protected PayOrderService $payOrderService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面跳转支付入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function submit(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), EpayV2Validator::class, 'submit');
|
||||
return $this->epayV2ProtocolService->submit($payload, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 下单入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), EpayV2Validator::class, 'create');
|
||||
return json($this->epayV2ProtocolService->create($payload, $request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付单查询入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function query(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), EpayV2Validator::class, 'query');
|
||||
return json($this->epayV2ProtocolService->query($payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款发起入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function refund(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), EpayV2Validator::class, 'refund');
|
||||
return json($this->epayV2ProtocolService->refund($payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款查询入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function refundQuery(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), EpayV2Validator::class, 'refund_query');
|
||||
return json($this->epayV2ProtocolService->refundQuery($payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭订单入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function close(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), EpayV2Validator::class, 'close');
|
||||
return json($this->epayV2ProtocolService->close($payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户信息查询入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function merchantInfo(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), EpayV2Validator::class, 'merchant_info');
|
||||
return json($this->epayV2ProtocolService->merchantInfo($payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户订单列表入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function merchantOrders(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), EpayV2Validator::class, 'merchant_orders');
|
||||
return json($this->epayV2ProtocolService->merchantOrders($payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 转账发起入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function transferSubmit(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), EpayV2Validator::class, 'transfer_submit');
|
||||
return json($this->epayV2ProtocolService->transferSubmit($payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 转账查询入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function transferQuery(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), EpayV2Validator::class, 'transfer_query');
|
||||
return json($this->epayV2ProtocolService->transferQuery($payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 转账余额查询入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function transferBalance(Request $request): Response
|
||||
{
|
||||
$payload = $this->validated($request->all(), EpayV2Validator::class, 'transfer_balance');
|
||||
return json($this->epayV2ProtocolService->transferBalance($payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道回调入口。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param string $payNo 支付单号
|
||||
* @return string|Response
|
||||
*/
|
||||
public function callback(Request $request, string $payNo): string|Response
|
||||
{
|
||||
return $this->payOrderService->handlePluginCallback($payNo, $request);
|
||||
}
|
||||
}
|
||||
68
app/http/api/validation/CashierValidator.php
Normal file
68
app/http/api/validation/CashierValidator.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\api\validation;
|
||||
|
||||
use support\validation\Validator;
|
||||
|
||||
/**
|
||||
* 收银台请求验证器。
|
||||
*
|
||||
* 定义收银台上下文查询与确认支付场景规则。
|
||||
*/
|
||||
class CashierValidator extends Validator
|
||||
{
|
||||
protected array $rules = [
|
||||
'biz_no' => 'required|string|max:32',
|
||||
'pay_no' => 'required|string|max:32',
|
||||
'type' => 'nullable|string|max:32',
|
||||
];
|
||||
|
||||
protected array $attributes = [
|
||||
'biz_no' => '业务单号',
|
||||
'pay_no' => '支付单号',
|
||||
'type' => '支付方式',
|
||||
];
|
||||
|
||||
protected array $scenes = [
|
||||
'context' => ['biz_no'],
|
||||
'confirm' => ['biz_no', 'type'],
|
||||
'pay_order' => ['pay_no'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 收银台上下文场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneContext(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'biz_no' => 'required|string|max:32',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收银台确认场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneConfirm(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'biz_no' => 'required|string|max:32',
|
||||
'type' => 'required|string|max:32',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付页详情场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function scenePayOrder(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'pay_no' => 'required|string|max:32',
|
||||
]);
|
||||
}
|
||||
}
|
||||
168
app/http/api/validation/EpayV1Validator.php
Normal file
168
app/http/api/validation/EpayV1Validator.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\api\validation;
|
||||
|
||||
use support\validation\Validator;
|
||||
|
||||
/**
|
||||
* ePay V1 请求验证器。
|
||||
*
|
||||
* 定义旧版提交、查询、退款与兼容接口的场景规则。
|
||||
*/
|
||||
class EpayV1Validator extends Validator
|
||||
{
|
||||
protected array $rules = [
|
||||
'act' => 'sometimes|string|in:query,settle,order,orders,refund',
|
||||
'pid' => 'required|integer|min:1',
|
||||
'key' => 'nullable|string|max:128',
|
||||
'type' => 'nullable|string|max:32',
|
||||
'trade_no' => 'nullable|string|max:64',
|
||||
'out_trade_no' => 'nullable|string|max:64',
|
||||
'notify_url' => 'nullable|string|max:255',
|
||||
'return_url' => 'nullable|string|max:255',
|
||||
'name' => 'nullable|string|max:255',
|
||||
'money' => 'nullable|regex:/^\d+(?:\.\d{1,2})?$/',
|
||||
'param' => 'nullable',
|
||||
'clientip' => 'nullable|ip',
|
||||
'device' => 'nullable|string|in:pc,mobile,qq,wechat,alipay,jump',
|
||||
'sign' => 'nullable|string|max:255',
|
||||
'sign_type' => 'sometimes|string|in:MD5',
|
||||
'page' => 'sometimes|integer|min:1',
|
||||
'limit' => 'sometimes|integer|min:1|max:50',
|
||||
];
|
||||
|
||||
protected array $attributes = [
|
||||
'act' => '操作类型',
|
||||
'pid' => '商户ID',
|
||||
'key' => '商户密钥',
|
||||
'type' => '支付方式',
|
||||
'trade_no' => '平台订单号',
|
||||
'out_trade_no' => '商户订单号',
|
||||
'notify_url' => '异步通知地址',
|
||||
'return_url' => '跳转通知地址',
|
||||
'name' => '商品名称',
|
||||
'money' => '商品金额',
|
||||
'param' => '业务扩展参数',
|
||||
'clientip' => '用户 IP',
|
||||
'device' => '设备类型',
|
||||
'sign' => '签名字符串',
|
||||
'sign_type' => '签名类型',
|
||||
'page' => '页码',
|
||||
'limit' => '数量',
|
||||
];
|
||||
|
||||
protected array $scenes = [
|
||||
'submit' => ['pid', 'type', 'out_trade_no', 'notify_url', 'return_url', 'name', 'money', 'param', 'sign', 'sign_type'],
|
||||
'mapi' => ['pid', 'type', 'out_trade_no', 'notify_url', 'return_url', 'name', 'money', 'param', 'clientip', 'device', 'sign', 'sign_type'],
|
||||
'api_query' => ['act', 'pid', 'key'],
|
||||
'api_settle' => ['act', 'pid', 'key'],
|
||||
'api_order' => ['act', 'pid', 'key', 'trade_no', 'out_trade_no'],
|
||||
'api_orders' => ['act', 'pid', 'key', 'page', 'limit'],
|
||||
'api_refund' => ['act', 'pid', 'key', 'trade_no', 'out_trade_no', 'money'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 页面跳转支付场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneSubmit(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'out_trade_no' => 'required|string|max:64',
|
||||
'notify_url' => 'required|string|max:255',
|
||||
'return_url' => 'required|string|max:255',
|
||||
'name' => 'required|string|max:255',
|
||||
'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/',
|
||||
'sign_type' => 'required|string|in:MD5',
|
||||
'sign' => 'required|string|max:255',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 支付场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneMapi(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'type' => 'required|string|max:32',
|
||||
'out_trade_no' => 'required|string|max:64',
|
||||
'notify_url' => 'required|string|max:255',
|
||||
'return_url' => 'nullable|string|max:255',
|
||||
'name' => 'required|string|max:255',
|
||||
'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/',
|
||||
'clientip' => 'required|ip',
|
||||
'sign_type' => 'required|string|in:MD5',
|
||||
'sign' => 'required|string|max:255',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户信息查询场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneApiQuery(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'key' => 'required|string|max:128',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算记录查询场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneApiSettle(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'key' => 'required|string|max:128',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个订单查询场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneApiOrder(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'key' => 'required|string|max:128',
|
||||
'trade_no' => 'nullable|string|max:64|required_without:out_trade_no',
|
||||
'out_trade_no' => 'nullable|string|max:64|required_without:trade_no',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单列表查询场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneApiOrders(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'key' => 'required|string|max:128',
|
||||
'page' => 'sometimes|integer|min:1',
|
||||
'limit' => 'sometimes|integer|min:1|max:50',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款申请场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneApiRefund(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'key' => 'required|string|max:128',
|
||||
'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/',
|
||||
'trade_no' => 'nullable|string|max:64|required_without:out_trade_no',
|
||||
'out_trade_no' => 'nullable|string|max:64|required_without:trade_no',
|
||||
]);
|
||||
}
|
||||
}
|
||||
216
app/http/api/validation/EpayV2Validator.php
Normal file
216
app/http/api/validation/EpayV2Validator.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\api\validation;
|
||||
|
||||
use support\validation\Validator;
|
||||
|
||||
/**
|
||||
* ePay V2 请求验证器。
|
||||
*
|
||||
* 定义新版支付、退款、商户与转账接口的场景规则。
|
||||
*/
|
||||
class EpayV2Validator extends Validator
|
||||
{
|
||||
protected array $rules = [
|
||||
'pid' => 'required|integer|min:1',
|
||||
'timestamp' => 'required|integer|min:1',
|
||||
// 兼容旧版 SDK 里使用的 `RSA` 简写,同时内部统一按 SHA256WithRSA 验签。
|
||||
'sign_type' => 'required|string|in:SHA256WithRSA,RSA',
|
||||
// RSA 签名是 Base64 文本,长度会明显超过 MD5,不能沿用 255 的短限制。
|
||||
'sign' => 'required|string|max:2048',
|
||||
'type' => 'nullable|string|max:32',
|
||||
'method' => 'nullable|string|in:web,jump,jsapi,app,scan,applet',
|
||||
'trade_no' => 'nullable|string|max:64',
|
||||
'out_trade_no' => 'nullable|string|max:64',
|
||||
'notify_url' => 'nullable|string|max:255',
|
||||
'return_url' => 'nullable|string|max:255',
|
||||
'name' => 'nullable|string|max:255',
|
||||
'money' => 'nullable|regex:/^\d+(?:\.\d{1,2})?$/',
|
||||
'param' => 'nullable',
|
||||
'auth_code' => 'nullable|string|max:128',
|
||||
'sub_openid' => 'nullable|string|max:128',
|
||||
'sub_appid' => 'nullable|string|max:64',
|
||||
'clientip' => 'nullable|ip',
|
||||
'device' => 'nullable|string|in:pc,mobile,qq,wechat,alipay',
|
||||
'channel_id' => 'nullable|integer|min:0',
|
||||
'offset' => 'sometimes|integer|min:0',
|
||||
'limit' => 'sometimes|integer|min:1|max:50',
|
||||
'status' => 'nullable|integer|min:0|max:5',
|
||||
'refund_no' => 'nullable|string|max:64',
|
||||
'out_refund_no' => 'nullable|string|max:64',
|
||||
'biz_no' => 'nullable|string|max:32',
|
||||
'out_biz_no' => 'nullable|string|max:64',
|
||||
'account' => 'nullable|string|max:100',
|
||||
'bookid' => 'nullable|string|max:64',
|
||||
'remark' => 'nullable|string|max:255',
|
||||
];
|
||||
|
||||
protected array $attributes = [
|
||||
'pid' => '商户ID',
|
||||
'timestamp' => '时间戳',
|
||||
'sign_type' => '签名类型',
|
||||
'sign' => '签名字符串',
|
||||
'type' => '支付方式',
|
||||
'method' => '接口类型',
|
||||
'trade_no' => '平台订单号',
|
||||
'out_trade_no' => '商户订单号',
|
||||
'notify_url' => '异步通知地址',
|
||||
'return_url' => '跳转通知地址',
|
||||
'name' => '商品名称',
|
||||
'money' => '商品金额',
|
||||
'param' => '业务扩展参数',
|
||||
'auth_code' => '授权码',
|
||||
'sub_openid' => '子用户 OPENID',
|
||||
'sub_appid' => '子应用 APPID',
|
||||
'clientip' => '用户 IP',
|
||||
'device' => '设备类型',
|
||||
'channel_id' => '渠道ID',
|
||||
'offset' => '偏移量',
|
||||
'limit' => '数量',
|
||||
'status' => '状态',
|
||||
'refund_no' => '退款单号',
|
||||
'out_refund_no' => '商户退款单号',
|
||||
'biz_no' => '平台业务号',
|
||||
'out_biz_no' => '商户转账单号',
|
||||
'account' => '收款账号',
|
||||
'bookid' => '书签ID',
|
||||
'remark' => '备注',
|
||||
];
|
||||
|
||||
protected array $scenes = [
|
||||
'submit' => ['pid', 'timestamp', 'sign_type', 'sign', 'type', 'out_trade_no', 'notify_url', 'return_url', 'name', 'money', 'param', 'channel_id'],
|
||||
'create' => ['pid', 'timestamp', 'sign_type', 'sign', 'type', 'method', 'out_trade_no', 'notify_url', 'return_url', 'name', 'money', 'param', 'auth_code', 'sub_openid', 'sub_appid', 'clientip', 'device', 'channel_id'],
|
||||
'query' => ['pid', 'timestamp', 'sign_type', 'sign', 'trade_no', 'out_trade_no'],
|
||||
'refund' => ['pid', 'timestamp', 'sign_type', 'sign', 'trade_no', 'out_trade_no', 'money', 'out_refund_no'],
|
||||
'refund_query' => ['pid', 'timestamp', 'sign_type', 'sign', 'refund_no', 'out_refund_no'],
|
||||
'close' => ['pid', 'timestamp', 'sign_type', 'sign', 'trade_no', 'out_trade_no'],
|
||||
'merchant_info' => ['pid', 'timestamp', 'sign_type', 'sign'],
|
||||
'merchant_orders' => ['pid', 'timestamp', 'sign_type', 'sign', 'offset', 'limit', 'status'],
|
||||
'transfer_submit' => ['pid', 'timestamp', 'sign_type', 'sign', 'type', 'account', 'name', 'money', 'out_biz_no', 'remark', 'bookid'],
|
||||
'transfer_query' => ['pid', 'timestamp', 'sign_type', 'sign', 'biz_no', 'out_biz_no'],
|
||||
'transfer_balance' => ['pid', 'timestamp', 'sign_type', 'sign'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 页面跳转支付场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneSubmit(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'type' => 'nullable|string|max:32',
|
||||
'out_trade_no' => 'required|string|max:64',
|
||||
'notify_url' => 'required|string|max:255',
|
||||
'return_url' => 'required|string|max:255',
|
||||
'name' => 'required|string|max:255',
|
||||
'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/',
|
||||
'sign_type' => 'required|string|in:SHA256WithRSA,RSA',
|
||||
'sign' => 'required|string|max:2048',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 下单场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneCreate(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'type' => 'required|string|max:32',
|
||||
'method' => 'required|string|in:web,jump,jsapi,app,scan,applet',
|
||||
'out_trade_no' => 'required|string|max:64',
|
||||
'notify_url' => 'required|string|max:255',
|
||||
'return_url' => 'nullable|string|max:255',
|
||||
'name' => 'required|string|max:255',
|
||||
'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/',
|
||||
'device' => 'nullable|string|in:pc,mobile,qq,wechat,alipay',
|
||||
'sign_type' => 'required|string|in:SHA256WithRSA,RSA',
|
||||
'sign' => 'required|string|max:2048',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款发起场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneRefund(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/',
|
||||
'trade_no' => 'nullable|string|max:64|required_without:out_trade_no',
|
||||
'out_trade_no' => 'nullable|string|max:64|required_without:trade_no',
|
||||
'out_refund_no' => 'nullable|string|max:64',
|
||||
'sign_type' => 'required|string|in:SHA256WithRSA,RSA',
|
||||
'sign' => 'required|string|max:2048',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付单查询场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneQuery(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'trade_no' => 'nullable|string|max:64|required_without:out_trade_no',
|
||||
'out_trade_no' => 'nullable|string|max:64|required_without:trade_no',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭订单场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneClose(): static
|
||||
{
|
||||
return $this->sceneQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款查询场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneRefundQuery(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'refund_no' => 'nullable|string|max:64|required_without:out_refund_no',
|
||||
'out_refund_no' => 'nullable|string|max:64|required_without:refund_no',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转账查询场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneTransferQuery(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'biz_no' => 'nullable|string|max:32|required_without:out_biz_no',
|
||||
'out_biz_no' => 'nullable|string|max:64|required_without:biz_no',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转账发起场景。
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function sceneTransferSubmit(): static
|
||||
{
|
||||
return $this->appendRules([
|
||||
'type' => 'required|string|in:alipay,wxpay,qqpay,bank',
|
||||
'account' => 'required|string|max:100',
|
||||
'name' => 'required|string|max:100',
|
||||
'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/',
|
||||
'sign_type' => 'required|string|in:SHA256WithRSA,RSA',
|
||||
'sign' => 'required|string|max:2048',
|
||||
]);
|
||||
}
|
||||
}
|
||||
103
app/http/mer/controller/file/FileRecordController.php
Normal file
103
app/http/mer/controller/file/FileRecordController.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\mer\controller\file;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\exception\ValidationException;
|
||||
use app\http\admin\validation\FileRecordValidator;
|
||||
use app\service\file\FileRecordService;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
use Webman\Http\UploadFile;
|
||||
|
||||
/**
|
||||
* 商户端文件控制器。
|
||||
*
|
||||
* 供插件配置动态表单中的上传字段使用。
|
||||
*
|
||||
* @property FileRecordService $fileRecordService 文件记录服务
|
||||
*/
|
||||
class FileRecordController extends BaseController
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param FileRecordService $fileRecordService 文件记录服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected FileRecordService $fileRecordService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件记录。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 响应对象
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function upload(Request $request): Response
|
||||
{
|
||||
$data = $this->validated(array_merge($this->payload($request), ['scene' => $request->input('scene')]), FileRecordValidator::class, 'store');
|
||||
$uploadedFile = $request->file('file');
|
||||
if ($uploadedFile === null) {
|
||||
throw new ValidationException('请先选择上传文件');
|
||||
}
|
||||
|
||||
$createdBy = $this->currentMerchantId($request);
|
||||
$createdByName = $this->currentMerchantNo($request);
|
||||
|
||||
if (is_array($uploadedFile)) {
|
||||
$items = [];
|
||||
foreach ($uploadedFile as $file) {
|
||||
if ($file instanceof UploadFile) {
|
||||
$items[] = $this->fileRecordService->upload($file, $data, $createdBy, $createdByName);
|
||||
}
|
||||
}
|
||||
|
||||
if ($items === []) {
|
||||
throw new ValidationException('上传文件无效');
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'list' => $items,
|
||||
'total' => count($items),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$uploadedFile instanceof UploadFile) {
|
||||
throw new ValidationException('上传文件无效');
|
||||
}
|
||||
|
||||
return $this->success($this->fileRecordService->upload($uploadedFile, $data, $createdBy, $createdByName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件预览响应。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param string $id 文件记录ID
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function preview(Request $request, string $id): Response
|
||||
{
|
||||
$data = $this->validated(['id' => (int) $id], FileRecordValidator::class, 'preview');
|
||||
|
||||
return $this->fileRecordService->previewResponse((int) $data['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件下载响应。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param string $id 文件记录ID
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function download(Request $request, string $id): Response
|
||||
{
|
||||
$data = $this->validated(['id' => (int) $id], FileRecordValidator::class, 'download');
|
||||
|
||||
return $this->fileRecordService->downloadResponse((int) $data['id']);
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,138 @@ class MerchantPortalController extends BaseController
|
||||
return $this->success($this->merchantPortalService->myChannels($payload, $merchantId, $page, $pageSize));
|
||||
}
|
||||
|
||||
public function channelCreateMeta(Request $request): Response
|
||||
{
|
||||
return $this->success($this->merchantPortalService->channelCreateMeta());
|
||||
}
|
||||
|
||||
public function createChannel(Request $request): Response
|
||||
{
|
||||
$merchantId = $this->currentMerchantId($request);
|
||||
if ($merchantId <= 0) {
|
||||
return $this->fail('未获取到当前商户信息', 401);
|
||||
}
|
||||
|
||||
$data = $this->validated($this->payload($request), MerchantPortalValidator::class, 'channelStore');
|
||||
|
||||
return $this->success($this->merchantPortalService->createChannel($merchantId, $data));
|
||||
}
|
||||
|
||||
public function updateChannel(Request $request, string $id): Response
|
||||
{
|
||||
$merchantId = $this->currentMerchantId($request);
|
||||
if ($merchantId <= 0) {
|
||||
return $this->fail('未获取到当前商户信息', 401);
|
||||
}
|
||||
|
||||
$data = $this->validated(
|
||||
array_merge($this->payload($request), ['id' => (int) $id]),
|
||||
MerchantPortalValidator::class,
|
||||
'channelUpdate'
|
||||
);
|
||||
|
||||
$channel = $this->merchantPortalService->updateChannel($merchantId, (int) $data['id'], $data);
|
||||
if (!$channel) {
|
||||
return $this->fail('通道不存在', 404);
|
||||
}
|
||||
|
||||
return $this->success($channel);
|
||||
}
|
||||
|
||||
public function deleteChannel(Request $request, string $id): Response
|
||||
{
|
||||
$merchantId = $this->currentMerchantId($request);
|
||||
if ($merchantId <= 0) {
|
||||
return $this->fail('未获取到当前商户信息', 401);
|
||||
}
|
||||
|
||||
$data = $this->validated(['id' => (int) $id], MerchantPortalValidator::class, 'channelDestroy');
|
||||
if (!$this->merchantPortalService->deleteChannel($merchantId, (int) $data['id'])) {
|
||||
return $this->fail('通道不存在', 404);
|
||||
}
|
||||
|
||||
return $this->success(true);
|
||||
}
|
||||
|
||||
public function pluginConfigs(Request $request): Response
|
||||
{
|
||||
$merchantId = $this->currentMerchantId($request);
|
||||
if ($merchantId <= 0) {
|
||||
return $this->fail('未获取到当前商户信息', 401);
|
||||
}
|
||||
|
||||
$payload = $this->validated($this->payload($request), MerchantPortalValidator::class, 'pluginConfigIndex');
|
||||
$page = max(1, (int) ($payload['page'] ?? 1));
|
||||
$pageSize = max(1, (int) ($payload['page_size'] ?? 10));
|
||||
|
||||
return $this->success($this->merchantPortalService->pluginConfigs($payload, $merchantId, $page, $pageSize));
|
||||
}
|
||||
|
||||
public function createPluginConfig(Request $request): Response
|
||||
{
|
||||
$merchantId = $this->currentMerchantId($request);
|
||||
if ($merchantId <= 0) {
|
||||
return $this->fail('未获取到当前商户信息', 401);
|
||||
}
|
||||
|
||||
$data = $this->validated($this->payload($request), MerchantPortalValidator::class, 'pluginConfigStore');
|
||||
|
||||
return $this->success($this->merchantPortalService->createPluginConfig($merchantId, $data));
|
||||
}
|
||||
|
||||
public function updatePluginConfig(Request $request, string $id): Response
|
||||
{
|
||||
$merchantId = $this->currentMerchantId($request);
|
||||
if ($merchantId <= 0) {
|
||||
return $this->fail('未获取到当前商户信息', 401);
|
||||
}
|
||||
|
||||
$data = $this->validated(
|
||||
array_merge($this->payload($request), ['id' => (int) $id]),
|
||||
MerchantPortalValidator::class,
|
||||
'pluginConfigUpdate'
|
||||
);
|
||||
|
||||
$config = $this->merchantPortalService->updatePluginConfig($merchantId, (int) $data['id'], $data);
|
||||
if (!$config) {
|
||||
return $this->fail('插件配置不存在', 404);
|
||||
}
|
||||
|
||||
return $this->success($config);
|
||||
}
|
||||
|
||||
public function deletePluginConfig(Request $request, string $id): Response
|
||||
{
|
||||
$merchantId = $this->currentMerchantId($request);
|
||||
if ($merchantId <= 0) {
|
||||
return $this->fail('未获取到当前商户信息', 401);
|
||||
}
|
||||
|
||||
$data = $this->validated(['id' => (int) $id], MerchantPortalValidator::class, 'pluginConfigDestroy');
|
||||
if (!$this->merchantPortalService->deletePluginConfig($merchantId, (int) $data['id'])) {
|
||||
return $this->fail('插件配置不存在', 404);
|
||||
}
|
||||
|
||||
return $this->success(true);
|
||||
}
|
||||
|
||||
public function pluginConfigOptions(Request $request): Response
|
||||
{
|
||||
$merchantId = $this->currentMerchantId($request);
|
||||
if ($merchantId <= 0) {
|
||||
return $this->fail('未获取到当前商户信息', 401);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'configs' => $this->merchantPortalService->pluginConfigOptions($merchantId, (string) $request->get('plugin_code', '')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function pluginSchema(Request $request, string $code): Response
|
||||
{
|
||||
return $this->success($this->merchantPortalService->pluginSchema($code));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路由解析结果。
|
||||
*
|
||||
@@ -113,7 +245,12 @@ class MerchantPortalController extends BaseController
|
||||
return $this->fail('未获取到当前商户信息', 401);
|
||||
}
|
||||
|
||||
$payload = $this->validated($this->payload($request), MerchantPortalValidator::class, 'routePreview');
|
||||
$rawPayload = $this->payload($request);
|
||||
if (empty($rawPayload['pay_type_id']) || empty($rawPayload['pay_amount'])) {
|
||||
return $this->success($this->merchantPortalService->routePreview($merchantId, 0, 0));
|
||||
}
|
||||
|
||||
$payload = $this->validated($rawPayload, MerchantPortalValidator::class, 'routePreview');
|
||||
$payTypeId = (int) ($payload['pay_type_id'] ?? 0);
|
||||
$payAmount = (int) ($payload['pay_amount'] ?? 0);
|
||||
$statDate = trim((string) ($payload['stat_date'] ?? ''));
|
||||
@@ -150,7 +287,9 @@ class MerchantPortalController extends BaseController
|
||||
return $this->fail('未获取到当前商户信息', 401);
|
||||
}
|
||||
|
||||
return $this->success($this->merchantPortalService->issueCredential($merchantId));
|
||||
$data = $this->validated($this->payload($request), MerchantPortalValidator::class, 'issueCredential');
|
||||
|
||||
return $this->success($this->merchantPortalService->issueCredential($merchantId, $data));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\http\mer\middleware;
|
||||
|
||||
use app\exception\UnauthorizedException;
|
||||
use app\service\merchant\auth\MerchantAuthService;
|
||||
use support\Context;
|
||||
use Webman\Http\Request;
|
||||
@@ -34,6 +35,7 @@ class MerchantAuthMiddleware implements MiddlewareInterface
|
||||
* @param Request $request 请求对象
|
||||
* @param callable $handler handler
|
||||
* @return Response 响应对象
|
||||
* @throws UnauthorizedException
|
||||
*/
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
@@ -42,11 +44,7 @@ class MerchantAuthMiddleware implements MiddlewareInterface
|
||||
|
||||
if ($token === '') {
|
||||
if ((int) env('AUTH_MIDDLEWARE_STRICT', 1) === 1) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'msg' => 'merchant unauthorized',
|
||||
'data' => null,
|
||||
]);
|
||||
throw new UnauthorizedException('商户未授权');
|
||||
}
|
||||
} else {
|
||||
$result = $this->merchantAuthService->authenticateToken(
|
||||
@@ -55,11 +53,7 @@ class MerchantAuthMiddleware implements MiddlewareInterface
|
||||
$request->header('user-agent', '')
|
||||
);
|
||||
if (!$result) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'msg' => 'merchant unauthorized',
|
||||
'data' => null,
|
||||
]);
|
||||
throw new UnauthorizedException('商户未授权');
|
||||
}
|
||||
|
||||
Context::set('auth.merchant_id', (int) $result['merchant']->id);
|
||||
|
||||
@@ -29,6 +29,26 @@ class MerchantPortalValidator extends Validator
|
||||
'pay_type_id' => 'required|integer|min:1',
|
||||
'pay_amount' => 'required|integer|min:1',
|
||||
'stat_date' => 'sometimes|date',
|
||||
'id' => 'sometimes|integer|min:1',
|
||||
'keyword' => 'sometimes|string|max:128',
|
||||
'plugin_code' => 'sometimes|string|alpha_dash|min:2|max:32',
|
||||
'config' => 'nullable|array',
|
||||
'settlement_cycle_type' => 'sometimes|integer|in:0,1,2,3,4',
|
||||
'settlement_cutoff_time' => 'nullable|date_format:H:i:s',
|
||||
'name' => 'sometimes|string|min:2|max:128',
|
||||
'api_config_id' => 'sometimes|integer|min:1',
|
||||
'daily_limit_amount' => 'nullable|integer|min:0',
|
||||
'daily_limit_count' => 'nullable|integer|min:0',
|
||||
'min_amount' => 'nullable|integer|min:0',
|
||||
'max_amount' => 'nullable|integer|min:0',
|
||||
'remark' => 'nullable|string|max:500',
|
||||
'status' => 'sometimes|integer|in:0,1',
|
||||
'rotate_v1' => 'sometimes|integer|in:0,1',
|
||||
'rotate_v2' => 'sometimes|integer|in:0,1',
|
||||
'sign_type' => 'sometimes|integer|in:0,1',
|
||||
'sort_no' => 'nullable|integer|min:0',
|
||||
'page' => 'sometimes|integer|min:1',
|
||||
'page_size' => 'sometimes|integer|min:1|max:100',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -51,6 +71,26 @@ class MerchantPortalValidator extends Validator
|
||||
'pay_type_id' => '支付方式',
|
||||
'pay_amount' => '支付金额',
|
||||
'stat_date' => '统计日期',
|
||||
'id' => '记录ID',
|
||||
'keyword' => '关键字',
|
||||
'plugin_code' => '支付插件',
|
||||
'config' => '插件配置',
|
||||
'settlement_cycle_type' => '结算周期',
|
||||
'settlement_cutoff_time' => '结算截止时间',
|
||||
'name' => '通道名称',
|
||||
'api_config_id' => '插件配置',
|
||||
'daily_limit_amount' => '单日限额',
|
||||
'daily_limit_count' => '单日限笔',
|
||||
'min_amount' => '单笔最小金额',
|
||||
'max_amount' => '单笔最大金额',
|
||||
'remark' => '备注',
|
||||
'status' => '状态',
|
||||
'rotate_v1' => 'V1 凭证',
|
||||
'rotate_v2' => 'V2 凭证',
|
||||
'sign_type' => '签名类型',
|
||||
'sort_no' => '排序',
|
||||
'page' => '页码',
|
||||
'page_size' => '每页条数',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -71,7 +111,47 @@ class MerchantPortalValidator extends Validator
|
||||
],
|
||||
'passwordUpdate' => ['current_password', 'password', 'password_confirm'],
|
||||
'routePreview' => ['pay_type_id', 'pay_amount', 'stat_date'],
|
||||
'pluginConfigIndex' => ['keyword', 'plugin_code', 'page', 'page_size'],
|
||||
'pluginConfigStore' => ['plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'],
|
||||
'pluginConfigUpdate' => ['id', 'plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'],
|
||||
'pluginConfigDestroy' => ['id'],
|
||||
'channelStore' => ['name', 'pay_type_id', 'plugin_code', 'api_config_id', 'daily_limit_amount', 'daily_limit_count', 'min_amount', 'max_amount', 'remark', 'status', 'sort_no'],
|
||||
'channelUpdate' => ['id', 'name', 'pay_type_id', 'plugin_code', 'api_config_id', 'daily_limit_amount', 'daily_limit_count', 'min_amount', 'max_amount', 'remark', 'status', 'sort_no'],
|
||||
'channelDestroy' => ['id'],
|
||||
'issueCredential' => ['rotate_v1', 'rotate_v2', 'sign_type', 'status'],
|
||||
];
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = parent::rules();
|
||||
|
||||
return match ($this->scene()) {
|
||||
'pluginConfigStore' => array_merge($rules, [
|
||||
'plugin_code' => 'required|string|alpha_dash|min:2|max:32',
|
||||
]),
|
||||
'pluginConfigUpdate' => array_merge($rules, [
|
||||
'id' => 'required|integer|min:1',
|
||||
'plugin_code' => 'required|string|alpha_dash|min:2|max:32',
|
||||
]),
|
||||
'pluginConfigDestroy', 'channelDestroy' => array_merge($rules, [
|
||||
'id' => 'required|integer|min:1',
|
||||
]),
|
||||
'channelStore' => array_merge($rules, [
|
||||
'name' => 'required|string|min:2|max:128',
|
||||
'pay_type_id' => 'required|integer|min:1',
|
||||
'plugin_code' => 'required|string|alpha_dash|min:2|max:32',
|
||||
'api_config_id' => 'required|integer|min:1',
|
||||
'status' => 'required|integer|in:0,1',
|
||||
]),
|
||||
'channelUpdate' => array_merge($rules, [
|
||||
'id' => 'required|integer|min:1',
|
||||
'name' => 'required|string|min:2|max:128',
|
||||
'pay_type_id' => 'required|integer|min:1',
|
||||
'plugin_code' => 'required|string|alpha_dash|min:2|max:32',
|
||||
'api_config_id' => 'required|integer|min:1',
|
||||
'status' => 'required|integer|in:0,1',
|
||||
]),
|
||||
default => $rules,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
124
app/listener/PaymentMerchantNotifyListener.php
Normal file
124
app/listener/PaymentMerchantNotifyListener.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace app\listener;
|
||||
|
||||
use app\model\payment\PayOrder;
|
||||
use app\model\payment\RefundOrder;
|
||||
use app\model\payment\SettlementOrder;
|
||||
use app\repository\payment\settlement\SettlementOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\repository\payment\trade\RefundOrderRepository;
|
||||
use app\service\payment\runtime\MerchantNotifyDispatcherService;
|
||||
use support\Log;
|
||||
|
||||
/**
|
||||
* 支付域商户通知监听器。
|
||||
*
|
||||
* 聚合支付、退款、清算等会触发商户通知的事件处理。
|
||||
*/
|
||||
class PaymentMerchantNotifyListener
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantNotifyDispatcherService $merchantNotifyDispatcherService,
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected RefundOrderRepository $refundOrderRepository,
|
||||
protected SettlementOrderRepository $settlementOrderRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功后创建并尝试派发商户通知。
|
||||
*
|
||||
* @param array<string, mixed> $payload 事件载荷
|
||||
* @param string $eventName 事件名称
|
||||
* @return void
|
||||
*/
|
||||
public function onPayOrderSucceeded(array $payload = [], string $eventName = ''): void
|
||||
{
|
||||
try {
|
||||
$payOrder = $payload['pay_order'] ?? null;
|
||||
if (!$payOrder instanceof PayOrder) {
|
||||
$payNo = trim((string) ($payload['pay_no'] ?? ''));
|
||||
$payOrder = $payNo !== '' ? $this->payOrderRepository->findByPayNo($payNo) : null;
|
||||
}
|
||||
|
||||
if (!$payOrder instanceof PayOrder) {
|
||||
Log::warning('[PaymentMerchantNotifyListener] 支付成功事件缺少可用支付单');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->merchantNotifyDispatcherService->enqueueAndDispatchPaySuccess($payOrder);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning(sprintf(
|
||||
'[PaymentMerchantNotifyListener] 商户支付通知创建失败 event=%s pay_no=%s error=%s',
|
||||
$eventName,
|
||||
(string) ($payload['pay_no'] ?? ''),
|
||||
$e->getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款成功后创建并尝试派发商户通知。
|
||||
*
|
||||
* @param array<string, mixed> $payload 事件载荷
|
||||
* @param string $eventName 事件名称
|
||||
* @return void
|
||||
*/
|
||||
public function onRefundOrderSucceeded(array $payload = [], string $eventName = ''): void
|
||||
{
|
||||
try {
|
||||
$refundOrder = $payload['refund_order'] ?? null;
|
||||
if (!$refundOrder instanceof RefundOrder) {
|
||||
$refundNo = trim((string) ($payload['refund_no'] ?? ''));
|
||||
$refundOrder = $refundNo !== '' ? $this->refundOrderRepository->findByRefundNo($refundNo) : null;
|
||||
}
|
||||
|
||||
if (!$refundOrder instanceof RefundOrder) {
|
||||
Log::warning('[PaymentMerchantNotifyListener] 退款成功事件缺少可用退款单');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->merchantNotifyDispatcherService->enqueueAndDispatchRefundSuccess($refundOrder);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning(sprintf(
|
||||
'[PaymentMerchantNotifyListener] 商户退款通知创建失败 event=%s refund_no=%s error=%s',
|
||||
$eventName,
|
||||
(string) ($payload['refund_no'] ?? ''),
|
||||
$e->getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清算成功后创建并尝试派发商户通知。
|
||||
*
|
||||
* @param array<string, mixed> $payload 事件载荷
|
||||
* @param string $eventName 事件名称
|
||||
* @return void
|
||||
*/
|
||||
public function onSettlementOrderSucceeded(array $payload = [], string $eventName = ''): void
|
||||
{
|
||||
try {
|
||||
$settlementOrder = $payload['settlement_order'] ?? null;
|
||||
if (!$settlementOrder instanceof SettlementOrder) {
|
||||
$settleNo = trim((string) ($payload['settle_no'] ?? ''));
|
||||
$settlementOrder = $settleNo !== '' ? $this->settlementOrderRepository->findBySettleNo($settleNo) : null;
|
||||
}
|
||||
|
||||
if (!$settlementOrder instanceof SettlementOrder) {
|
||||
Log::warning('[PaymentMerchantNotifyListener] 清算成功事件缺少可用清算单');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->merchantNotifyDispatcherService->enqueueAndDispatchSettlementSuccess($settlementOrder);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning(sprintf(
|
||||
'[PaymentMerchantNotifyListener] 商户清算通知创建失败 event=%s settle_no=%s error=%s',
|
||||
$eventName,
|
||||
(string) ($payload['settle_no'] ?? ''),
|
||||
$e->getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\listener;
|
||||
|
||||
use app\service\system\config\SystemConfigRuntimeService;
|
||||
use support\Log;
|
||||
|
||||
/**
|
||||
* 系统配置变更监听器。
|
||||
@@ -31,10 +32,18 @@ class SystemConfigChangedListener
|
||||
*/
|
||||
public function refreshRuntimeCache(array $payload = [], string $eventName = ''): void
|
||||
{
|
||||
$this->systemConfigRuntimeService->refresh();
|
||||
try {
|
||||
$this->systemConfigRuntimeService->refresh();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning(sprintf(
|
||||
'[SystemConfigChangedListener] 系统配置运行时缓存刷新失败 event=%s group_code=%s error=%s',
|
||||
$eventName,
|
||||
(string) ($payload['group_code'] ?? ''),
|
||||
$e->getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ class ChannelNotifyLog extends BaseModel
|
||||
protected $casts = [
|
||||
'channel_id' => 'integer',
|
||||
'notify_type' => 'integer',
|
||||
'raw_payload' => 'array',
|
||||
'verify_status' => 'integer',
|
||||
'process_status' => 'integer',
|
||||
'retry_count' => 'integer',
|
||||
@@ -65,4 +66,3 @@ class ChannelNotifyLog extends BaseModel
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -34,9 +34,11 @@ class PayCallbackLog extends BaseModel
|
||||
'channel_id',
|
||||
'callback_type',
|
||||
'request_data',
|
||||
'request_hash',
|
||||
'verify_status',
|
||||
'process_status',
|
||||
'process_result',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -57,11 +59,12 @@ class PayCallbackLog extends BaseModel
|
||||
protected $casts = [
|
||||
'channel_id' => 'integer',
|
||||
'callback_type' => 'integer',
|
||||
'request_data' => 'array',
|
||||
'request_hash' => 'string',
|
||||
'verify_status' => 'integer',
|
||||
'process_status' => 'integer',
|
||||
'process_result' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ class Merchant extends BaseModel
|
||||
'settlement_account_no',
|
||||
'settlement_bank_name',
|
||||
'settlement_bank_branch',
|
||||
'pay_status',
|
||||
'settle_status',
|
||||
'settle_type',
|
||||
'status',
|
||||
'last_login_at',
|
||||
'last_login_ip',
|
||||
@@ -62,6 +65,9 @@ class Merchant extends BaseModel
|
||||
'merchant_type' => 'integer',
|
||||
'group_id' => 'integer',
|
||||
'risk_level' => 'integer',
|
||||
'pay_status' => 'integer',
|
||||
'settle_status' => 'integer',
|
||||
'settle_type' => 'integer',
|
||||
'status' => 'integer',
|
||||
'last_login_at' => 'datetime',
|
||||
'password_updated_at' => 'datetime',
|
||||
@@ -70,4 +76,3 @@ class Merchant extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 商户对外接口凭证模型。
|
||||
* 保存商户 API 凭证、签名类型、启用状态和最近使用时间。
|
||||
* 保存商户 API 凭证、商户公钥、签名类型、启用状态和最近使用时间。
|
||||
*/
|
||||
class MerchantApiCredential extends BaseModel
|
||||
{
|
||||
@@ -26,6 +26,7 @@ class MerchantApiCredential extends BaseModel
|
||||
'merchant_id',
|
||||
'sign_type',
|
||||
'api_key',
|
||||
'merchant_public_key',
|
||||
'status',
|
||||
'last_used_at',
|
||||
];
|
||||
@@ -53,4 +54,3 @@ class MerchantApiCredential extends BaseModel
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 业务订单模型。
|
||||
* 表示商户业务侧原始订单,支付单和退款单都从这里展开。
|
||||
* 表示商户业务侧原始订单,只保留业务事实与收银台恢复所需字段。
|
||||
*/
|
||||
class BizOrder extends BaseModel
|
||||
{
|
||||
@@ -26,11 +26,13 @@ class BizOrder extends BaseModel
|
||||
'biz_no',
|
||||
'trace_no',
|
||||
'merchant_id',
|
||||
'merchant_group_id',
|
||||
'poll_group_id',
|
||||
'merchant_order_no',
|
||||
'subject',
|
||||
'body',
|
||||
'notify_url',
|
||||
'return_url',
|
||||
'client_ip',
|
||||
'device',
|
||||
'order_amount',
|
||||
'paid_amount',
|
||||
'refund_amount',
|
||||
@@ -52,8 +54,10 @@ class BizOrder extends BaseModel
|
||||
*/
|
||||
protected $casts = [
|
||||
'merchant_id' => 'integer',
|
||||
'merchant_group_id' => 'integer',
|
||||
'poll_group_id' => 'integer',
|
||||
'notify_url' => 'string',
|
||||
'return_url' => 'string',
|
||||
'client_ip' => 'string',
|
||||
'device' => 'string',
|
||||
'order_amount' => 'integer',
|
||||
'paid_amount' => 'integer',
|
||||
'refund_amount' => 'integer',
|
||||
@@ -70,6 +74,3 @@ class BizOrder extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ class NotifyTask extends BaseModel
|
||||
*/
|
||||
protected $fillable = [
|
||||
'notify_no',
|
||||
'event_type',
|
||||
'ref_no',
|
||||
'merchant_id',
|
||||
'merchant_group_id',
|
||||
'biz_no',
|
||||
@@ -53,8 +55,11 @@ class NotifyTask extends BaseModel
|
||||
* @var mixed
|
||||
*/
|
||||
protected $casts = [
|
||||
'event_type' => 'string',
|
||||
'ref_no' => 'string',
|
||||
'merchant_id' => 'integer',
|
||||
'merchant_group_id' => 'integer',
|
||||
'notify_data' => 'array',
|
||||
'status' => 'integer',
|
||||
'retry_count' => 'integer',
|
||||
'next_retry_at' => 'datetime',
|
||||
@@ -65,5 +70,3 @@ class NotifyTask extends BaseModel
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ class PayOrder extends BaseModel
|
||||
'channel_type',
|
||||
'channel_mode',
|
||||
'pay_amount',
|
||||
'notify_url',
|
||||
'return_url',
|
||||
'client_ip',
|
||||
'device',
|
||||
'fee_rate_bp_snapshot',
|
||||
'split_rate_bp_snapshot',
|
||||
'fee_estimated_amount',
|
||||
@@ -74,6 +78,10 @@ class PayOrder extends BaseModel
|
||||
'channel_type' => 'integer',
|
||||
'channel_mode' => 'integer',
|
||||
'pay_amount' => 'integer',
|
||||
'notify_url' => 'string',
|
||||
'return_url' => 'string',
|
||||
'client_ip' => 'string',
|
||||
'device' => 'string',
|
||||
'fee_rate_bp_snapshot' => 'integer',
|
||||
'split_rate_bp_snapshot' => 'integer',
|
||||
'fee_estimated_amount' => 'integer',
|
||||
@@ -96,5 +104,3 @@ class PayOrder extends BaseModel
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ class PaymentPlugin extends BaseModel
|
||||
'version',
|
||||
'author',
|
||||
'link',
|
||||
'allow_merchant',
|
||||
'status',
|
||||
'remark',
|
||||
];
|
||||
@@ -66,6 +67,7 @@ class PaymentPlugin extends BaseModel
|
||||
'config_schema' => 'array',
|
||||
'pay_types' => 'array',
|
||||
'transfer_types' => 'array',
|
||||
'allow_merchant' => 'integer',
|
||||
'status' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
@@ -74,4 +76,3 @@ class PaymentPlugin extends BaseModel
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class PaymentPluginConf extends BaseModel
|
||||
* @var mixed
|
||||
*/
|
||||
protected $fillable = [
|
||||
'merchant_id',
|
||||
'plugin_code',
|
||||
'config',
|
||||
'settlement_cycle_type',
|
||||
@@ -36,6 +37,7 @@ class PaymentPluginConf extends BaseModel
|
||||
* @var mixed
|
||||
*/
|
||||
protected $casts = [
|
||||
'merchant_id' => 'integer',
|
||||
'config' => 'array',
|
||||
'settlement_cycle_type' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
@@ -45,4 +47,3 @@ class PaymentPluginConf extends BaseModel
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
57
app/model/payment/TransferOrder.php
Normal file
57
app/model/payment/TransferOrder.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace app\model\payment;
|
||||
|
||||
use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 转账单模型。
|
||||
*/
|
||||
class TransferOrder extends BaseModel
|
||||
{
|
||||
protected $table = 'ma_transfer_order';
|
||||
|
||||
protected $fillable = [
|
||||
'biz_no',
|
||||
'trace_no',
|
||||
'merchant_id',
|
||||
'merchant_group_id',
|
||||
'out_biz_no',
|
||||
'type',
|
||||
'account',
|
||||
'name',
|
||||
'amount',
|
||||
'cost_amount',
|
||||
'remark',
|
||||
'bookid',
|
||||
'channel_id',
|
||||
'channel_request_no',
|
||||
'channel_order_no',
|
||||
'channel_trade_no',
|
||||
'channel_error_code',
|
||||
'channel_error_msg',
|
||||
'status',
|
||||
'request_at',
|
||||
'processing_at',
|
||||
'succeeded_at',
|
||||
'failed_at',
|
||||
'ext_json',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'merchant_id' => 'integer',
|
||||
'merchant_group_id' => 'integer',
|
||||
'channel_id' => 'integer',
|
||||
'amount' => 'integer',
|
||||
'cost_amount' => 'integer',
|
||||
'status' => 'integer',
|
||||
'request_at' => 'datetime',
|
||||
'processing_at' => 'datetime',
|
||||
'succeeded_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
'ext_json' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
221
app/process/PaymentRuntimeProcess.php
Normal file
221
app/process/PaymentRuntimeProcess.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use app\service\payment\runtime\PaymentRuntimeMaintenanceService;
|
||||
use app\service\system\config\SystemConfigRuntimeService;
|
||||
use support\Log;
|
||||
use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
/**
|
||||
* 支付运行时维护进程。
|
||||
*
|
||||
* 使用 Webman 自定义进程承载轻量定时任务,避免把通知重试和主动查单塞进请求链路。
|
||||
*/
|
||||
class PaymentRuntimeProcess
|
||||
{
|
||||
/**
|
||||
* 任务上次执行时间。
|
||||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private array $lastRunAt = [];
|
||||
|
||||
/**
|
||||
* 任务运行锁。
|
||||
*
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private array $running = [];
|
||||
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param array<string, mixed> $options 进程选项
|
||||
*/
|
||||
public function __construct(
|
||||
private array $options = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker 启动时注册心跳定时器。
|
||||
*
|
||||
* @param Worker $worker Worker 实例
|
||||
* @return void
|
||||
*/
|
||||
public function onWorkerStart(Worker $worker): void
|
||||
{
|
||||
$heartbeat = $this->intOption('heartbeat_seconds', 5, 1, 60);
|
||||
|
||||
Timer::add($heartbeat, function (): void {
|
||||
$this->tick();
|
||||
});
|
||||
|
||||
Log::info(sprintf('[PaymentRuntimeProcess] 支付运行时维护进程已启动 heartbeat=%s', $heartbeat));
|
||||
}
|
||||
|
||||
/**
|
||||
* 心跳调度入口。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function tick(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->boolConfig('pay_runtime_enabled', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runIfDue(
|
||||
'notify_retry',
|
||||
$this->intConfig('pay_notify_retry_scan_interval_seconds', 60, 5),
|
||||
fn (): array => $this->maintenanceService()->retryMerchantNotifies(
|
||||
$this->intConfig('pay_notify_retry_batch_size', 100, 1)
|
||||
)
|
||||
);
|
||||
|
||||
$this->runIfDue(
|
||||
'order_timeout',
|
||||
$this->intConfig('pay_order_timeout_scan_interval_seconds', 60, 5),
|
||||
fn (): array => $this->maintenanceService()->timeoutExpiredPayOrders(
|
||||
$this->intConfig('pay_order_timeout_batch_size', 100, 1)
|
||||
)
|
||||
);
|
||||
|
||||
if ($this->boolConfig('pay_active_query_enabled', true)) {
|
||||
$this->runIfDue(
|
||||
'active_query',
|
||||
$this->intConfig('pay_active_query_interval_seconds', 60, 10),
|
||||
fn (): array => $this->maintenanceService()->syncPayingOrdersByQuery(
|
||||
$this->intConfig('pay_active_query_batch_size', 50, 1),
|
||||
$this->intConfig('pay_active_query_min_age_seconds', 60, 1)
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[PaymentRuntimeProcess] 心跳调度失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 到期后执行任务,并避免同类任务重叠运行。
|
||||
*
|
||||
* @param string $key 任务键
|
||||
* @param int $intervalSeconds 执行间隔
|
||||
* @param callable $callback 任务回调
|
||||
* @return void
|
||||
*/
|
||||
private function runIfDue(string $key, int $intervalSeconds, callable $callback): void
|
||||
{
|
||||
$now = time();
|
||||
$lastRunAt = (int) ($this->lastRunAt[$key] ?? 0);
|
||||
if ($lastRunAt > 0 && $now - $lastRunAt < $intervalSeconds) {
|
||||
return;
|
||||
}
|
||||
if (!empty($this->running[$key])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->lastRunAt[$key] = $now;
|
||||
$this->running[$key] = true;
|
||||
|
||||
try {
|
||||
$summary = $callback();
|
||||
if ($this->hasWork($summary)) {
|
||||
Log::info(sprintf(
|
||||
'[PaymentRuntimeProcess] %s 执行完成 %s',
|
||||
$key,
|
||||
json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning(sprintf('[PaymentRuntimeProcess] %s 执行失败:%s', $key, $e->getMessage()));
|
||||
} finally {
|
||||
$this->running[$key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断任务摘要里是否有实际工作量。
|
||||
*
|
||||
* @param array<string, int> $summary 任务摘要
|
||||
* @return bool 是否有工作量
|
||||
*/
|
||||
private function hasWork(array $summary): bool
|
||||
{
|
||||
foreach ($summary as $value) {
|
||||
if ((int) $value > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取维护服务。
|
||||
*
|
||||
* @return PaymentRuntimeMaintenanceService 维护服务
|
||||
*/
|
||||
private function maintenanceService(): PaymentRuntimeMaintenanceService
|
||||
{
|
||||
return container_make(PaymentRuntimeMaintenanceService::class, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取布尔系统配置。
|
||||
*
|
||||
* @param string $key 配置键
|
||||
* @param bool $default 默认值
|
||||
* @return bool 配置值
|
||||
*/
|
||||
private function boolConfig(string $key, bool $default): bool
|
||||
{
|
||||
$value = strtolower(trim($this->runtimeConfig()->get($key, $default ? '1' : '0')));
|
||||
|
||||
return in_array($value, ['1', 'true', 'yes', 'on', 'enabled'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取整数系统配置。
|
||||
*
|
||||
* @param string $key 配置键
|
||||
* @param int $default 默认值
|
||||
* @param int $min 最小值
|
||||
* @return int 配置值
|
||||
*/
|
||||
private function intConfig(string $key, int $default, int $min = 1): int
|
||||
{
|
||||
$value = (int) $this->runtimeConfig()->get($key, $default);
|
||||
|
||||
return max($min, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取整数构造选项。
|
||||
*
|
||||
* @param string $key 配置键
|
||||
* @param int $default 默认值
|
||||
* @param int $min 最小值
|
||||
* @param int $max 最大值
|
||||
* @return int 配置值
|
||||
*/
|
||||
private function intOption(string $key, int $default, int $min, int $max): int
|
||||
{
|
||||
$value = (int) ($this->options[$key] ?? $default);
|
||||
|
||||
return min($max, max($min, $value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统配置运行时服务。
|
||||
*
|
||||
* @return SystemConfigRuntimeService 运行时配置服务
|
||||
*/
|
||||
private function runtimeConfig(): SystemConfigRuntimeService
|
||||
{
|
||||
return container_make(SystemConfigRuntimeService::class, []);
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,27 @@ class PaymentChannelRepository extends BaseRepository
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断指定商户的通道名称是否已存在。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param string $name 通道名称
|
||||
* @param int $ignoreId 需要排除的记录ID
|
||||
* @return bool 是否存在
|
||||
*/
|
||||
public function existsByMerchantName(int $merchantId, string $name, int $ignoreId = 0): bool
|
||||
{
|
||||
$query = $this->model->newQuery()
|
||||
->where('merchant_id', $merchantId)
|
||||
->where('name', $name);
|
||||
|
||||
if ($ignoreId > 0) {
|
||||
$query->where('id', '<>', $ignoreId);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计商户名下的支付通道概览。
|
||||
*
|
||||
@@ -104,4 +125,3 @@ class PaymentChannelRepository extends BaseRepository
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -32,14 +32,30 @@ class PaymentPluginConfRepository extends BaseRepository
|
||||
public function findByPluginCode(string $pluginCode, array $columns = ['*'])
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('merchant_id', 0)
|
||||
->where('plugin_code', $pluginCode)
|
||||
->orderByDesc('id')
|
||||
->first($columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前商户可访问的插件配置。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param int $id 配置ID
|
||||
* @param array $columns 字段列表
|
||||
* @return PaymentPluginConf|null 插件配置记录
|
||||
*/
|
||||
public function findByMerchantAndId(int $merchantId, int $id, array $columns = ['*'])
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('merchant_id', $merchantId)
|
||||
->whereKey($id)
|
||||
->first($columns);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -49,10 +49,40 @@ class PaymentPluginRepository extends BaseRepository
|
||||
->orderBy('code', 'asc')
|
||||
->get($columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商户端允许使用的支付插件。
|
||||
*
|
||||
* @param array $columns 字段列表
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, PaymentPlugin> 插件列表
|
||||
*/
|
||||
public function merchantEnabledList(array $columns = ['*'])
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('status', 1)
|
||||
->where('allow_merchant', 1)
|
||||
->orderBy('code', 'asc')
|
||||
->get($columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户端允许使用的支付插件。
|
||||
*
|
||||
* @param string $code 插件编码
|
||||
* @param array $columns 字段列表
|
||||
* @return PaymentPlugin|null 插件记录
|
||||
*/
|
||||
public function findMerchantAllowed(string $code, array $columns = ['*']): ?PaymentPlugin
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->whereKey($code)
|
||||
->where('status', 1)
|
||||
->where('allow_merchant', 1)
|
||||
->first($columns);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,37 @@ class NotifyTaskRepository extends BaseRepository
|
||||
->first($columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据通知事件和引用单号查询通知任务。
|
||||
*
|
||||
* @param string $eventType 通知事件类型
|
||||
* @param string $refNo 事件引用单号
|
||||
* @param array $columns 字段列表
|
||||
* @return NotifyTask|null 通知任务记录
|
||||
*/
|
||||
public function findByEventRef(string $eventType, string $refNo, array $columns = ['*'])
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('event_type', $eventType)
|
||||
->where('ref_no', $refNo)
|
||||
->first($columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定支付单的通知任务列表。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $columns 字段列表
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, NotifyTask> 通知任务列表
|
||||
*/
|
||||
public function listByPayNo(string $payNo, array $columns = ['*'])
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('pay_no', $payNo)
|
||||
->orderByDesc('id')
|
||||
->get($columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询可重试的通知任务列表。
|
||||
*
|
||||
@@ -47,6 +78,8 @@ class NotifyTaskRepository extends BaseRepository
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('status', $status)
|
||||
->whereNotNull('next_retry_at')
|
||||
->where('next_retry_at', '<=', date('Y-m-d H:i:s'))
|
||||
->orderBy('next_retry_at')
|
||||
->get($columns);
|
||||
}
|
||||
@@ -54,6 +87,3 @@ class NotifyTaskRepository extends BaseRepository
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\repository\payment\trade;
|
||||
|
||||
use app\common\base\BaseRepository;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\model\payment\PayOrder;
|
||||
|
||||
/**
|
||||
@@ -159,6 +160,47 @@ class PayOrderRepository extends BaseRepository
|
||||
->first($columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询已过期但还未进入终态的支付单。
|
||||
*
|
||||
* @param string $now 当前时间
|
||||
* @param int $limit 限制条数
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, PayOrder> 支付单列表
|
||||
*/
|
||||
public function listExpiredMutable(string $now, int $limit = 100)
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->whereIn('status', TradeConstant::orderMutableStatuses())
|
||||
->whereNotNull('expire_at')
|
||||
->where('expire_at', '<=', $now)
|
||||
->orderBy('expire_at')
|
||||
->orderBy('id')
|
||||
->limit(max(1, $limit))
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询需要主动查单的支付中订单。
|
||||
*
|
||||
* @param string $before 最早拉起时间
|
||||
* @param int $limit 限制条数
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, PayOrder> 支付单列表
|
||||
*/
|
||||
public function listPayingForActiveQuery(string $before, int $limit = 50)
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('status', TradeConstant::ORDER_STATUS_PAYING)
|
||||
->where('request_at', '<=', $before)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expire_at')
|
||||
->orWhere('expire_at', '>', date('Y-m-d H:i:s'));
|
||||
})
|
||||
->orderBy('request_at')
|
||||
->orderBy('id')
|
||||
->limit(max(1, $limit))
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户最近支付单列表,用于总览展示。
|
||||
*
|
||||
@@ -189,4 +231,3 @@ class PayOrderRepository extends BaseRepository
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ class RefundOrderRepository extends BaseRepository
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('pay_no', $payNo)
|
||||
->orderByDesc('id')
|
||||
->first($columns);
|
||||
}
|
||||
|
||||
@@ -151,6 +152,7 @@ class RefundOrderRepository extends BaseRepository
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('pay_no', $payNo)
|
||||
->orderByDesc('id')
|
||||
->lockForUpdate()
|
||||
->first($columns);
|
||||
}
|
||||
@@ -171,5 +173,3 @@ class RefundOrderRepository extends BaseRepository
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
50
app/repository/payment/trade/TransferOrderRepository.php
Normal file
50
app/repository/payment/trade/TransferOrderRepository.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace app\repository\payment\trade;
|
||||
|
||||
use app\common\base\BaseRepository;
|
||||
use app\model\payment\TransferOrder;
|
||||
|
||||
/**
|
||||
* 转账单仓库。
|
||||
*/
|
||||
class TransferOrderRepository extends BaseRepository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new TransferOrder());
|
||||
}
|
||||
|
||||
public function findByBizNo(string $bizNo, array $columns = ['*'])
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('biz_no', $bizNo)
|
||||
->first($columns);
|
||||
}
|
||||
|
||||
public function findByOutBizNo(int $merchantId, string $outBizNo, array $columns = ['*'])
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('merchant_id', $merchantId)
|
||||
->where('out_biz_no', $outBizNo)
|
||||
->first($columns);
|
||||
}
|
||||
|
||||
public function findForUpdateByBizNo(string $bizNo, array $columns = ['*'])
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('biz_no', $bizNo)
|
||||
->lockForUpdate()
|
||||
->first($columns);
|
||||
}
|
||||
|
||||
public function findForUpdateByOutBizNo(int $merchantId, string $outBizNo, array $columns = ['*'])
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('merchant_id', $merchantId)
|
||||
->where('out_biz_no', $outBizNo)
|
||||
->lockForUpdate()
|
||||
->first($columns);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use app\http\admin\controller\account\MerchantAccountController;
|
||||
use app\http\admin\controller\account\MerchantAccountLedgerController;
|
||||
use app\http\admin\controller\ops\ChannelDailyStatController;
|
||||
use app\http\admin\controller\ops\ChannelNotifyLogController;
|
||||
use app\http\admin\controller\ops\MerchantNotifyTaskController;
|
||||
use app\http\admin\controller\merchant\MerchantController;
|
||||
use app\http\admin\controller\merchant\MerchantApiCredentialController;
|
||||
use app\http\admin\controller\merchant\MerchantGroupController;
|
||||
@@ -39,6 +40,7 @@ Route::group('/adminapi', function () {
|
||||
Route::group('', function () {
|
||||
Route::post('/logout', [AuthController::class, 'logout'])->name('adminApiAuthLogout')->setParams(['real_name' => '退出登录']);
|
||||
Route::get('/user/profile', [AuthController::class, 'profile'])->name('adminApiUserProfile')->setParams(['real_name' => '当前用户资料']);
|
||||
Route::post('/user/change-password', [AuthController::class, 'changePassword'])->name('adminApiUserChangePassword')->setParams(['real_name' => '修改当前管理员密码']);
|
||||
|
||||
Route::get('/merchants', [MerchantController::class, 'index'])->name('adminApiMerchantsIndex')->setParams(['real_name' => '商户列表']);
|
||||
Route::get('/merchants/options', [MerchantController::class, 'options'])->name('adminApiMerchantsOptions')->setParams(['real_name' => '商户选项']);
|
||||
@@ -144,6 +146,7 @@ Route::group('/adminapi', function () {
|
||||
Route::delete('/file-asset/{id}', [FileRecordController::class, 'destroy'])->name('adminApiFileRecordDestroy')->setParams(['real_name' => '删除文件']);
|
||||
|
||||
Route::get('/pay-orders', [PayOrderController::class, 'index'])->name('adminApiPayOrdersIndex')->setParams(['real_name' => '支付订单列表']);
|
||||
Route::get('/pay-orders/{payNo}', [PayOrderController::class, 'show'])->name('adminApiPayOrdersShow')->setParams(['real_name' => '支付订单详情']);
|
||||
Route::get('/refund-orders', [RefundOrderController::class, 'index'])->name('adminApiRefundOrdersIndex')->setParams(['real_name' => '退款订单列表']);
|
||||
Route::get('/refund-orders/{refundNo}', [RefundOrderController::class, 'show'])->name('adminApiRefundOrdersShow')->setParams(['real_name' => '退款订单详情']);
|
||||
Route::post('/refund-orders/{refundNo}/retry', [RefundOrderController::class, 'retry'])->name('adminApiRefundOrdersRetry')->setParams(['real_name' => '退款重试']);
|
||||
@@ -157,6 +160,10 @@ Route::group('/adminapi', function () {
|
||||
Route::get('/pay-callback-logs', [PayCallbackLogController::class, 'index'])->name('adminApiPayCallbackLogsIndex')->setParams(['real_name' => '支付回调日志列表']);
|
||||
Route::get('/pay-callback-logs/{id}', [PayCallbackLogController::class, 'show'])->name('adminApiPayCallbackLogsShow')->setParams(['real_name' => '支付回调日志详情']);
|
||||
|
||||
Route::get('/merchant-notify-tasks', [MerchantNotifyTaskController::class, 'index'])->name('adminApiMerchantNotifyTasksIndex')->setParams(['real_name' => '商户通知任务列表']);
|
||||
Route::get('/merchant-notify-tasks/{notifyNo}', [MerchantNotifyTaskController::class, 'show'])->name('adminApiMerchantNotifyTasksShow')->setParams(['real_name' => '商户通知任务详情']);
|
||||
Route::post('/merchant-notify-tasks/{notifyNo}/retry', [MerchantNotifyTaskController::class, 'retry'])->name('adminApiMerchantNotifyTasksRetry')->setParams(['real_name' => '商户通知任务重试']);
|
||||
|
||||
Route::get('/merchant-accounts', [MerchantAccountController::class, 'index'])->name('adminApiMerchantAccountsIndex')->setParams(['real_name' => '资金账户列表']);
|
||||
Route::get('/merchant-accounts/summary', [MerchantAccountController::class, 'summary'])->name('adminApiMerchantAccountsSummary')->setParams(['real_name' => '资金账户总览']);
|
||||
Route::get('/merchant-accounts/{id}', [MerchantAccountController::class, 'show'])->name('adminApiMerchantAccountsShow')->setParams(['real_name' => '资金账户详情']);
|
||||
|
||||
@@ -2,59 +2,69 @@
|
||||
|
||||
use Webman\Route;
|
||||
use app\common\middleware\Cors;
|
||||
use app\http\api\controller\adapter\EpayController;
|
||||
use app\http\api\controller\notify\NotifyController;
|
||||
use app\http\api\controller\trade\PayController;
|
||||
use app\http\api\controller\trade\RefundController;
|
||||
use app\http\api\controller\route\RouteController;
|
||||
use app\http\api\controller\settlement\SettlementController;
|
||||
use app\http\api\controller\trace\TraceController;
|
||||
use app\http\api\controller\cashier\CashierController;
|
||||
use app\http\api\controller\epay\EpayV1Controller;
|
||||
use app\http\api\controller\epay\EpayV2Controller;
|
||||
|
||||
Route::any('/pay[/{path:.+}]', function () {
|
||||
return view('/public/cashier/index');
|
||||
$serveCashierApp = static function () {
|
||||
$indexPath = public_path('cashier/index.html');
|
||||
if (!is_file($indexPath)) {
|
||||
return response('Cashier page not found', 404);
|
||||
}
|
||||
|
||||
return response(file_get_contents($indexPath), 200, [
|
||||
'Content-Type' => 'text/html; charset=utf-8',
|
||||
]);
|
||||
};
|
||||
|
||||
// ePay V1 旧版接口
|
||||
Route::group('', function () {
|
||||
Route::any('/submit.php', [EpayV1Controller::class, 'submit'])->name('epayV1Submit')->setParams(['real_name' => 'ePay V1 页面跳转支付']);
|
||||
Route::post('/mapi.php', [EpayV1Controller::class, 'mapi'])->name('epayV1Mapi')->setParams(['real_name' => 'ePay V1 接口支付']);
|
||||
Route::any('/api.php', [EpayV1Controller::class, 'api'])->name('epayV1Api')->setParams(['real_name' => 'ePay V1 标准 API']);
|
||||
})->middleware([Cors::class]);
|
||||
|
||||
// 收银台路由
|
||||
Route::group('/api/cashier', function () {
|
||||
Route::get('/context', [CashierController::class, 'context'])->name('cashierContext')->setParams(['real_name' => '收银台上下文']);
|
||||
Route::post('/confirm', [CashierController::class, 'confirm'])->name('cashierConfirm')->setParams(['real_name' => '收银台确认支付']);
|
||||
Route::get('/pay-order', [CashierController::class, 'payOrder'])->name('cashierPayOrder')->setParams(['real_name' => '收银台支付单详情']);
|
||||
})->middleware([Cors::class]);
|
||||
|
||||
Route::group('/cashier', function () use ($serveCashierApp) {
|
||||
Route::get('', $serveCashierApp)->name('cashierIndex')->setParams(['real_name' => '收银台首页']);
|
||||
Route::any('/{bizNo:.+}', $serveCashierApp)->name('cashierDetail')->setParams(['real_name' => '收银台详情页']);
|
||||
});
|
||||
|
||||
Route::group('', function () {
|
||||
Route::any('/submit.php', [EpayController::class, 'submit'])->name('epaySubmit')->setParams(['real_name' => 'Epay页面跳转支付']);
|
||||
Route::post('/mapi.php', [EpayController::class, 'mapi'])->name('epayMapi')->setParams(['real_name' => 'Epay接口支付']);
|
||||
Route::any('/api.php', [EpayController::class, 'api'])->name('epayApi')->setParams(['real_name' => 'Epay标准API']);
|
||||
})->middleware([Cors::class]);
|
||||
Route::group('/payment', function () use ($serveCashierApp) {
|
||||
Route::get('', $serveCashierApp)->name('paymentIndex')->setParams(['real_name' => '支付页首页']);
|
||||
Route::any('/{path:.+}', $serveCashierApp)->name('paymentDetail')->setParams(['real_name' => '支付页详情']);
|
||||
});
|
||||
|
||||
// ePay V2 新版接口
|
||||
Route::group('/api', function () {
|
||||
// 支付模块
|
||||
Route::group('/pay', function () {
|
||||
Route::post('/prepare', [PayController::class, 'prepare'])->name('payPrepare')->setParams(['real_name' => '支付预下单']);
|
||||
Route::get('/{payNo}', [PayController::class, 'show'])->name('payDetail')->setParams(['real_name' => '查询支付单']);
|
||||
Route::post('/{payNo}/close', [PayController::class, 'close'])->name('payClose')->setParams(['real_name' => '关闭支付单']);
|
||||
Route::post('/{payNo}/timeout', [PayController::class, 'timeout'])->name('payTimeout')->setParams(['real_name' => '支付超时']);
|
||||
Route::any('/{payNo}/callback', [PayController::class, 'callback'])->name('payChannelCallback')->setParams(['real_name' => '第三方支付回调']);
|
||||
Route::post('/callback/mock', [PayController::class, 'callback'])->name('payCallbackMock')->setParams(['real_name' => '支付回调模拟入口']);
|
||||
// 文档约定是 POST,同时兼容旧版 SDK `getPayLink()` 生成的 GET 请求。
|
||||
Route::any('/submit', [EpayV2Controller::class, 'submit'])->name('epayV2PaySubmit')->setParams(['real_name' => 'ePay V2 页面跳转支付']);
|
||||
Route::post('/create', [EpayV2Controller::class, 'create'])->name('epayV2PayCreate')->setParams(['real_name' => 'ePay V2 创建订单']);
|
||||
Route::post('/query', [EpayV2Controller::class, 'query'])->name('epayV2PayQuery')->setParams(['real_name' => 'ePay V2 查询订单']);
|
||||
Route::post('/refund', [EpayV2Controller::class, 'refund'])->name('epayV2PayRefund')->setParams(['real_name' => 'ePay V2 退款']);
|
||||
Route::post('/refundquery', [EpayV2Controller::class, 'refundQuery'])->name('epayV2PayRefundQuery')->setParams(['real_name' => 'ePay V2 退款查询']);
|
||||
Route::post('/close', [EpayV2Controller::class, 'close'])->name('epayV2PayClose')->setParams(['real_name' => 'ePay V2 关闭订单']);
|
||||
Route::any('/{payNo}/callback', [EpayV2Controller::class, 'callback'])->name('epayPayCallback')->setParams(['real_name' => '支付渠道回调']);
|
||||
});
|
||||
|
||||
Route::group('/refunds', function () {
|
||||
Route::post('/', [RefundController::class, 'create'])->name('refundCreate')->setParams(['real_name' => '创建退款单']);
|
||||
Route::get('/{refundNo}', [RefundController::class, 'show'])->name('refundDetail')->setParams(['real_name' => '查询退款单']);
|
||||
Route::post('/{refundNo}/processing', [RefundController::class, 'processing'])->name('refundProcessing')->setParams(['real_name' => '退款处理中']);
|
||||
Route::post('/{refundNo}/retry', [RefundController::class, 'retry'])->name('refundRetry')->setParams(['real_name' => '退款重试']);
|
||||
Route::post('/{refundNo}/fail', [RefundController::class, 'markFail'])->name('refundFail')->setParams(['real_name' => '退款失败']);
|
||||
// 商户模块
|
||||
Route::group('/merchant', function () {
|
||||
Route::post('/info', [EpayV2Controller::class, 'merchantInfo'])->name('epayV2MerchantInfo')->setParams(['real_name' => 'ePay V2 商户信息']);
|
||||
Route::post('/orders', [EpayV2Controller::class, 'merchantOrders'])->name('epayV2MerchantOrders')->setParams(['real_name' => 'ePay V2 商户订单']);
|
||||
});
|
||||
|
||||
Route::group('/settlements', function () {
|
||||
Route::post('/', [SettlementController::class, 'create'])->name('settlementCreate')->setParams(['real_name' => '创建清结算单']);
|
||||
Route::get('/{settleNo}', [SettlementController::class, 'show'])->name('settlementDetail')->setParams(['real_name' => '查询清结算单']);
|
||||
Route::post('/{settleNo}/complete', [SettlementController::class, 'complete'])->name('settlementComplete')->setParams(['real_name' => '清结算成功']);
|
||||
Route::post('/{settleNo}/fail', [SettlementController::class, 'failSettlement'])->name('settlementFail')->setParams(['real_name' => '清结算失败']);
|
||||
});
|
||||
|
||||
Route::group('/routes', function () {
|
||||
Route::get('/resolve', [RouteController::class, 'resolve'])->name('routeResolve')->setParams(['real_name' => '解析路由']);
|
||||
});
|
||||
|
||||
Route::group('/traces', function () {
|
||||
Route::get('/{traceNo}', [TraceController::class, 'show'])->name('traceDetail')->setParams(['real_name' => '追踪查询']);
|
||||
});
|
||||
|
||||
Route::group('/notify', function () {
|
||||
Route::post('/channel', [NotifyController::class, 'channel'])->name('notifyChannel')->setParams(['real_name' => '渠道通知']);
|
||||
Route::post('/merchant', [NotifyController::class, 'merchant'])->name('notifyMerchant')->setParams(['real_name' => '商户通知']);
|
||||
// 转账模块
|
||||
Route::group('/transfer', function () {
|
||||
Route::post('/submit', [EpayV2Controller::class, 'transferSubmit'])->name('epayV2TransferSubmit')->setParams(['real_name' => 'ePay V2 转账提交']);
|
||||
Route::post('/query', [EpayV2Controller::class, 'transferQuery'])->name('epayV2TransferQuery')->setParams(['real_name' => 'ePay V2 转账查询']);
|
||||
Route::post('/balance', [EpayV2Controller::class, 'transferBalance'])->name('epayV2TransferBalance')->setParams(['real_name' => 'ePay V2 转账余额']);
|
||||
});
|
||||
})->middleware([Cors::class]);
|
||||
|
||||
@@ -7,6 +7,7 @@ use app\http\mer\controller\merchant\MerchantPortalController;
|
||||
use app\http\mer\controller\trade\RefundOrderController;
|
||||
use app\http\mer\controller\trade\PayOrderController;
|
||||
use app\http\mer\controller\system\SystemController;
|
||||
use app\http\mer\controller\file\FileRecordController;
|
||||
use app\http\mer\middleware\MerchantAuthMiddleware;
|
||||
|
||||
Route::any('/mer[/{path:.+}]', function () {
|
||||
@@ -23,6 +24,19 @@ Route::group('/merapi', function () {
|
||||
Route::put('/merchant/profile', [MerchantPortalController::class, 'updateProfile'])->name('merchantApiPortalProfileUpdate')->setParams(['real_name' => '更新商户资料']);
|
||||
Route::post('/merchant/change-password', [MerchantPortalController::class, 'changePassword'])->name('merchantApiPortalChangePassword')->setParams(['real_name' => '修改登录密码']);
|
||||
Route::get('/my-channels', [MerchantPortalController::class, 'myChannels'])->name('merchantApiPortalMyChannels')->setParams(['real_name' => '我的通道']);
|
||||
Route::get('/my-channels/create-meta', [MerchantPortalController::class, 'channelCreateMeta'])->name('merchantApiPortalChannelCreateMeta')->setParams(['real_name' => '商户通道配置元数据']);
|
||||
Route::post('/my-channels', [MerchantPortalController::class, 'createChannel'])->name('merchantApiPortalChannelCreate')->setParams(['real_name' => '新增商户通道']);
|
||||
Route::put('/my-channels/{id}', [MerchantPortalController::class, 'updateChannel'])->name('merchantApiPortalChannelUpdate')->setParams(['real_name' => '修改商户通道']);
|
||||
Route::delete('/my-channels/{id}', [MerchantPortalController::class, 'deleteChannel'])->name('merchantApiPortalChannelDelete')->setParams(['real_name' => '删除商户通道']);
|
||||
Route::get('/plugin-configs', [MerchantPortalController::class, 'pluginConfigs'])->name('merchantApiPortalPluginConfigs')->setParams(['real_name' => '商户插件配置']);
|
||||
Route::get('/plugin-configs/options', [MerchantPortalController::class, 'pluginConfigOptions'])->name('merchantApiPortalPluginConfigOptions')->setParams(['real_name' => '商户插件配置选项']);
|
||||
Route::post('/plugin-configs', [MerchantPortalController::class, 'createPluginConfig'])->name('merchantApiPortalPluginConfigCreate')->setParams(['real_name' => '新增商户插件配置']);
|
||||
Route::put('/plugin-configs/{id}', [MerchantPortalController::class, 'updatePluginConfig'])->name('merchantApiPortalPluginConfigUpdate')->setParams(['real_name' => '修改商户插件配置']);
|
||||
Route::delete('/plugin-configs/{id}', [MerchantPortalController::class, 'deletePluginConfig'])->name('merchantApiPortalPluginConfigDelete')->setParams(['real_name' => '删除商户插件配置']);
|
||||
Route::get('/payment-plugins/{code}/schema', [MerchantPortalController::class, 'pluginSchema'])->name('merchantApiPortalPluginSchema')->setParams(['real_name' => '商户插件配置结构']);
|
||||
Route::post('/file-asset/upload', [FileRecordController::class, 'upload'])->name('merchantApiFileRecordUpload')->setParams(['real_name' => '上传文件']);
|
||||
Route::get('/file-asset/{id}/preview', [FileRecordController::class, 'preview'])->name('merchantApiFileRecordPreview')->setParams(['real_name' => '文件预览']);
|
||||
Route::get('/file-asset/{id}/download', [FileRecordController::class, 'download'])->name('merchantApiFileRecordDownload')->setParams(['real_name' => '文件下载']);
|
||||
Route::get('/route-preview', [MerchantPortalController::class, 'routePreview'])->name('merchantApiPortalRoutePreview')->setParams(['real_name' => '路由解析']);
|
||||
Route::get('/api-credential', [MerchantPortalController::class, 'apiCredential'])->name('merchantApiPortalCredential')->setParams(['real_name' => '商户 API 凭证']);
|
||||
Route::post('/api-credential/issue-credential', [MerchantPortalController::class, 'issueCredential'])->name('merchantApiPortalIssueCredential')->setParams(['real_name' => '生成或重置商户 API 凭证']);
|
||||
|
||||
@@ -263,24 +263,28 @@ class MerchantCommandService extends BaseService
|
||||
/**
|
||||
* 生成或重置商户 API 凭证。
|
||||
*
|
||||
* 支持分别重置 V1 API Key 和 V2 RSA 密钥对。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param array<string, mixed> $options 生成选项
|
||||
* @return array 凭证数据
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
public function issueCredential(int $merchantId): array
|
||||
public function issueCredential(int $merchantId, array $options = []): array
|
||||
{
|
||||
$merchant = $this->merchantQueryService->findById($merchantId);
|
||||
if (!$merchant) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
$credentialValue = $this->merchantApiCredentialService->issueCredential($merchantId);
|
||||
$credential = $this->merchantApiCredentialService->findByMerchantId($merchantId);
|
||||
$result = $this->merchantApiCredentialService->issueCredentialBundle($merchantId, $options);
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'credential_value' => $credentialValue,
|
||||
'credential' => $credential,
|
||||
'credential_value' => $result['credential_value'] ?? '',
|
||||
'merchant_private_key' => $result['merchant_private_key'] ?? '',
|
||||
'credential' => $result['credential'] ?? null,
|
||||
'generated' => $result['generated'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -330,6 +334,24 @@ class MerchantCommandService extends BaseService
|
||||
return $merchant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验商户是否允许发起支付。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @return Merchant 商户模型
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function ensureMerchantPayEnabled(int $merchantId): Merchant
|
||||
{
|
||||
$merchant = $this->ensureMerchantEnabled($merchantId);
|
||||
if ((int) ($merchant->pay_status ?? 1) !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new BusinessStateException('商户支付已关闭', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
return $merchant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验商户分组是否启用。
|
||||
*
|
||||
|
||||
@@ -199,11 +199,12 @@ class MerchantService extends BaseService
|
||||
* 生成或重置商户 API 凭证。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param array<string, mixed> $options 生成选项
|
||||
* @return array 凭证数据
|
||||
*/
|
||||
public function issueCredential(int $merchantId): array
|
||||
public function issueCredential(int $merchantId, array $options = []): array
|
||||
{
|
||||
return $this->commandService->issueCredential($merchantId);
|
||||
return $this->commandService->issueCredential($merchantId, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,6 +240,17 @@ class MerchantService extends BaseService
|
||||
return $this->commandService->ensureMerchantEnabled($merchantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验商户是否允许发起支付。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @return Merchant 商户模型
|
||||
*/
|
||||
public function ensureMerchantPayEnabled(int $merchantId): Merchant
|
||||
{
|
||||
return $this->commandService->ensureMerchantPayEnabled($merchantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验商户分组是否启用。
|
||||
*
|
||||
@@ -261,5 +273,3 @@ class MerchantService extends BaseService
|
||||
return $this->queryService->findPolicy($merchantId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\service\merchant\auth;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\util\JwtTokenManager;
|
||||
use app\exception\ValidationException;
|
||||
@@ -50,7 +51,7 @@ class MerchantAuthService extends BaseService
|
||||
$merchant = $this->merchantPortalSupportService->merchantSummary($merchantId);
|
||||
$credential = $merchantId > 0 ? $this->merchantApiCredentialRepository->findByMerchantId($merchantId) : null;
|
||||
|
||||
$isCredentialEnabled = (int) ($credential->status ?? 0) === 1;
|
||||
$isCredentialEnabled = (int) ($credential->status ?? 0) === AuthConstant::CREDENTIAL_STATUS_ENABLED;
|
||||
$user = [
|
||||
'id' => $merchantId,
|
||||
'deptId' => (string) ($merchant['merchant_group_id'] ?? 0),
|
||||
@@ -104,7 +105,7 @@ class MerchantAuthService extends BaseService
|
||||
*/
|
||||
public function authenticateToken(string $token, string $ip = '', string $userAgent = ''): ?array
|
||||
{
|
||||
$result = $this->jwtTokenManager->verify('merchant', $token, $ip, $userAgent);
|
||||
$result = $this->jwtTokenManager->verify(AuthConstant::GUARD_MERCHANT, $token, $ip, $userAgent);
|
||||
if ($result === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -172,7 +173,7 @@ class MerchantAuthService extends BaseService
|
||||
*/
|
||||
public function revokeToken(string $token): bool
|
||||
{
|
||||
return $this->jwtTokenManager->revoke('merchant', $token);
|
||||
return $this->jwtTokenManager->revoke(AuthConstant::GUARD_MERCHANT, $token);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,7 +196,7 @@ class MerchantAuthService extends BaseService
|
||||
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
|
||||
$issued = $this->jwtTokenManager->issue('merchant', [
|
||||
$issued = $this->jwtTokenManager->issue(AuthConstant::GUARD_MERCHANT, [
|
||||
'sub' => (string) $merchantId,
|
||||
'merchant_id' => (int) $merchant->id,
|
||||
'merchant_no' => (string) $merchant->merchant_no,
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\exception\PaymentException;
|
||||
use app\model\payment\PaymentChannel;
|
||||
use app\model\payment\PaymentPlugin;
|
||||
use app\model\payment\PaymentPluginConf;
|
||||
use app\repository\payment\config\PaymentChannelRepository;
|
||||
use app\repository\payment\config\PaymentPluginConfRepository;
|
||||
use app\repository\payment\config\PaymentPluginRepository;
|
||||
use app\repository\payment\config\PaymentTypeRepository;
|
||||
|
||||
/**
|
||||
* 商户门户通道配置命令服务。
|
||||
*
|
||||
* 负责商户端插件配置、通道配置的新增修改删除,并集中校验商户归属与插件授权。
|
||||
*/
|
||||
class MerchantPortalChannelCommandService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalSupportService $supportService,
|
||||
protected PaymentPluginRepository $paymentPluginRepository,
|
||||
protected PaymentPluginConfRepository $paymentPluginConfRepository,
|
||||
protected PaymentChannelRepository $paymentChannelRepository,
|
||||
protected PaymentTypeRepository $paymentTypeRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户端允许使用的插件选项。
|
||||
*
|
||||
* @return array 插件选项和支付方式
|
||||
*/
|
||||
public function createMeta(): array
|
||||
{
|
||||
$plugins = $this->paymentPluginRepository->merchantEnabledList([
|
||||
'code',
|
||||
'name',
|
||||
'config_schema',
|
||||
'pay_types',
|
||||
])->map(function (PaymentPlugin $plugin): array {
|
||||
return $this->pluginOption($plugin);
|
||||
})->values()->all();
|
||||
|
||||
return [
|
||||
'plugins' => $plugins,
|
||||
'pay_types' => $this->supportService->enabledPayTypeOptions(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户插件配置列表。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $merchantId 商户ID
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return array 列表数据
|
||||
*/
|
||||
public function pluginConfigs(array $filters, int $merchantId, int $page, int $pageSize): array
|
||||
{
|
||||
$query = $this->paymentPluginConfRepository->query()
|
||||
->from('ma_payment_plugin_conf as c')
|
||||
->leftJoin('ma_payment_plugin as p', 'c.plugin_code', '=', 'p.code')
|
||||
->select([
|
||||
'c.id',
|
||||
'c.merchant_id',
|
||||
'c.plugin_code',
|
||||
'c.config',
|
||||
'c.settlement_cycle_type',
|
||||
'c.settlement_cutoff_time',
|
||||
'c.remark',
|
||||
'c.created_at',
|
||||
'c.updated_at',
|
||||
])
|
||||
->selectRaw("COALESCE(NULLIF(p.name, ''), c.plugin_code) AS plugin_name")
|
||||
->where('c.merchant_id', $merchantId);
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('c.plugin_code', 'like', '%' . $keyword . '%')
|
||||
->orWhere('p.name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.remark', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$pluginCode = trim((string) ($filters['plugin_code'] ?? ''));
|
||||
if ($pluginCode !== '') {
|
||||
$query->where('c.plugin_code', $pluginCode);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderByDesc('c.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
$row->settlement_cycle_type_text = $this->textFromMap((int) $row->settlement_cycle_type, [
|
||||
0 => 'D0',
|
||||
1 => 'D1',
|
||||
2 => 'D7',
|
||||
3 => 'T1',
|
||||
4 => 'OTHER',
|
||||
]);
|
||||
$row->created_at_text = $this->formatDateTime($row->created_at ?? null);
|
||||
$row->updated_at_text = $this->formatDateTime($row->updated_at ?? null);
|
||||
|
||||
return $row;
|
||||
});
|
||||
|
||||
return [
|
||||
'merchant' => $this->supportService->merchantSummary($merchantId),
|
||||
'plugins' => $this->createMeta()['plugins'],
|
||||
'list' => $paginator->items(),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增商户插件配置。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentPluginConf 配置
|
||||
*/
|
||||
public function createPluginConfig(int $merchantId, array $data): PaymentPluginConf
|
||||
{
|
||||
$payload = $this->normalizePluginConfigPayload($merchantId, $data);
|
||||
$this->assertMerchantPluginAllowed((string) $payload['plugin_code']);
|
||||
|
||||
return $this->paymentPluginConfRepository->create($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改商户插件配置。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param int $id 配置ID
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentPluginConf|null 配置
|
||||
*/
|
||||
public function updatePluginConfig(int $merchantId, int $id, array $data): ?PaymentPluginConf
|
||||
{
|
||||
$model = $this->paymentPluginConfRepository->findByMerchantAndId($merchantId, $id);
|
||||
if (!$model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $this->normalizePluginConfigPayload($merchantId, $data);
|
||||
$this->assertMerchantPluginAllowed((string) $payload['plugin_code']);
|
||||
|
||||
$model->fill($payload);
|
||||
$model->save();
|
||||
|
||||
return $model->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除商户插件配置。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param int $id 配置ID
|
||||
* @return bool 是否删除
|
||||
*/
|
||||
public function deletePluginConfig(int $merchantId, int $id): bool
|
||||
{
|
||||
$model = $this->paymentPluginConfRepository->findByMerchantAndId($merchantId, $id);
|
||||
if (!$model) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->paymentChannelRepository->existsBy([
|
||||
'merchant_id' => $merchantId,
|
||||
'api_config_id' => $id,
|
||||
])) {
|
||||
throw new PaymentException('该配置已被通道使用,不能删除', 40241);
|
||||
}
|
||||
|
||||
return (bool) $model->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户插件配置下拉选项。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param string $pluginCode 插件编码
|
||||
* @return array 配置选项
|
||||
*/
|
||||
public function pluginConfigOptions(int $merchantId, string $pluginCode = ''): array
|
||||
{
|
||||
$query = $this->paymentPluginConfRepository->query()
|
||||
->from('ma_payment_plugin_conf as c')
|
||||
->leftJoin('ma_payment_plugin as p', 'c.plugin_code', '=', 'p.code')
|
||||
->select(['c.id', 'c.plugin_code'])
|
||||
->selectRaw("COALESCE(NULLIF(p.name, ''), c.plugin_code) AS plugin_name")
|
||||
->where('c.merchant_id', $merchantId)
|
||||
->orderByDesc('c.id');
|
||||
|
||||
$pluginCode = trim($pluginCode);
|
||||
if ($pluginCode !== '') {
|
||||
$query->where('c.plugin_code', $pluginCode);
|
||||
}
|
||||
|
||||
return $query->get()->map(function ($row): array {
|
||||
return [
|
||||
'label' => sprintf('%s(%d)', (string) $row->plugin_name, (int) $row->id),
|
||||
'value' => (int) $row->id,
|
||||
'plugin_code' => (string) $row->plugin_code,
|
||||
'plugin_name' => (string) $row->plugin_name,
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增商户通道。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentChannel 通道
|
||||
*/
|
||||
public function createChannel(int $merchantId, array $data): PaymentChannel
|
||||
{
|
||||
$payload = $this->normalizeChannelPayload($merchantId, $data);
|
||||
$this->assertChannelWritable($merchantId, $payload);
|
||||
$this->assertChannelNameUnique($merchantId, (string) $payload['name']);
|
||||
|
||||
return $this->paymentChannelRepository->create($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改商户通道。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param int $id 通道ID
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentChannel|null 通道
|
||||
*/
|
||||
public function updateChannel(int $merchantId, int $id, array $data): ?PaymentChannel
|
||||
{
|
||||
$model = $this->paymentChannelRepository->findByMerchantAndId($merchantId, $id);
|
||||
if (!$model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $this->normalizeChannelPayload($merchantId, $data);
|
||||
$this->assertChannelWritable($merchantId, $payload);
|
||||
$this->assertChannelNameUnique($merchantId, (string) $payload['name'], $id);
|
||||
|
||||
$model->fill($payload);
|
||||
$model->save();
|
||||
|
||||
return $model->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除商户通道。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param int $id 通道ID
|
||||
* @return bool 是否删除
|
||||
*/
|
||||
public function deleteChannel(int $merchantId, int $id): bool
|
||||
{
|
||||
$model = $this->paymentChannelRepository->findByMerchantAndId($merchantId, $id);
|
||||
if (!$model) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $model->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据插件编码查询商户端可用插件结构。
|
||||
*
|
||||
* @param string $pluginCode 插件编码
|
||||
* @return array 配置结构
|
||||
*/
|
||||
public function pluginSchema(string $pluginCode): array
|
||||
{
|
||||
$plugin = $this->assertMerchantPluginAllowed($pluginCode);
|
||||
|
||||
return [
|
||||
'config_schema' => is_array($plugin->config_schema) ? array_values($plugin->config_schema) : [],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePluginConfigPayload(int $merchantId, array $data): array
|
||||
{
|
||||
return [
|
||||
'merchant_id' => $merchantId,
|
||||
'plugin_code' => trim((string) ($data['plugin_code'] ?? '')),
|
||||
'config' => is_array($data['config'] ?? null) ? $data['config'] : [],
|
||||
'settlement_cycle_type' => (int) ($data['settlement_cycle_type'] ?? 1),
|
||||
'settlement_cutoff_time' => trim((string) ($data['settlement_cutoff_time'] ?? '23:59:59')) ?: '23:59:59',
|
||||
'remark' => trim((string) ($data['remark'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeChannelPayload(int $merchantId, array $data): array
|
||||
{
|
||||
return [
|
||||
'merchant_id' => $merchantId,
|
||||
'name' => trim((string) ($data['name'] ?? '')),
|
||||
'split_rate_bp' => 10000,
|
||||
'cost_rate_bp' => 0,
|
||||
'channel_mode' => RouteConstant::CHANNEL_MODE_SELF,
|
||||
'pay_type_id' => (int) ($data['pay_type_id'] ?? 0),
|
||||
'plugin_code' => trim((string) ($data['plugin_code'] ?? '')),
|
||||
'api_config_id' => (int) ($data['api_config_id'] ?? 0),
|
||||
'daily_limit_amount' => max(0, (int) ($data['daily_limit_amount'] ?? 0)),
|
||||
'daily_limit_count' => max(0, (int) ($data['daily_limit_count'] ?? 0)),
|
||||
'min_amount' => max(0, (int) ($data['min_amount'] ?? 0)),
|
||||
'max_amount' => max(0, (int) ($data['max_amount'] ?? 0)),
|
||||
'remark' => trim((string) ($data['remark'] ?? '')),
|
||||
'status' => (int) ($data['status'] ?? CommonConstant::STATUS_ENABLED),
|
||||
'sort_no' => max(0, (int) ($data['sort_no'] ?? 0)),
|
||||
];
|
||||
}
|
||||
|
||||
private function assertChannelWritable(int $merchantId, array $payload): void
|
||||
{
|
||||
if ((string) $payload['name'] === '') {
|
||||
throw new PaymentException('通道名称不能为空', 40242);
|
||||
}
|
||||
|
||||
$plugin = $this->assertMerchantPluginAllowed((string) $payload['plugin_code']);
|
||||
$config = $this->paymentPluginConfRepository->findByMerchantAndId($merchantId, (int) $payload['api_config_id']);
|
||||
if (!$config || (string) $config->plugin_code !== (string) $payload['plugin_code']) {
|
||||
throw new PaymentException('插件配置不存在或不属于当前插件', 40243);
|
||||
}
|
||||
|
||||
$payType = $this->paymentTypeRepository->find((int) $payload['pay_type_id']);
|
||||
if (!$payType) {
|
||||
throw new PaymentException('支付方式不存在', 40244);
|
||||
}
|
||||
|
||||
$payTypes = is_array($plugin->pay_types) ? $plugin->pay_types : [];
|
||||
$payTypeCodes = array_values(array_filter(array_map(static fn ($item) => trim((string) $item), $payTypes)));
|
||||
if (!in_array((string) $payType->code, $payTypeCodes, true)) {
|
||||
throw new PaymentException('支付插件不支持当前支付方式', 40245);
|
||||
}
|
||||
|
||||
if ((int) $payload['max_amount'] > 0 && (int) $payload['min_amount'] > (int) $payload['max_amount']) {
|
||||
throw new PaymentException('单笔最小金额不能大于最大金额', 40246);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertChannelNameUnique(int $merchantId, string $name, int $ignoreId = 0): void
|
||||
{
|
||||
if ($this->paymentChannelRepository->existsByMerchantName($merchantId, $name, $ignoreId)) {
|
||||
throw new PaymentException('通道名称已存在', 40247);
|
||||
}
|
||||
|
||||
if ($this->paymentChannelRepository->existsByName($name, $ignoreId)) {
|
||||
throw new PaymentException('通道名称已被占用,请换一个名称', 40248);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertMerchantPluginAllowed(string $pluginCode): PaymentPlugin
|
||||
{
|
||||
$plugin = $this->paymentPluginRepository->findMerchantAllowed($pluginCode);
|
||||
if (!$plugin) {
|
||||
throw new PaymentException('该支付插件未开放给商户端使用', 40240, [
|
||||
'plugin_code' => $pluginCode,
|
||||
]);
|
||||
}
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
|
||||
private function pluginOption(PaymentPlugin $plugin): array
|
||||
{
|
||||
return [
|
||||
'label' => sprintf('%s(%s)', (string) $plugin->name, (string) $plugin->code),
|
||||
'value' => (string) $plugin->code,
|
||||
'code' => (string) $plugin->code,
|
||||
'name' => (string) $plugin->name,
|
||||
'pay_types' => is_array($plugin->pay_types) ? array_values($plugin->pay_types) : [],
|
||||
'config_schema' => is_array($plugin->config_schema) ? array_values($plugin->config_schema) : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use app\common\base\BaseService;
|
||||
* 商户门户通道服务。
|
||||
*
|
||||
* @property MerchantPortalChannelQueryService $queryService 查询服务
|
||||
* @property MerchantPortalChannelCommandService $commandService 命令服务
|
||||
* @property MerchantPortalRoutePreviewService $routePreviewService 路由解析服务
|
||||
*/
|
||||
class MerchantPortalChannelService extends BaseService
|
||||
@@ -16,10 +17,12 @@ class MerchantPortalChannelService extends BaseService
|
||||
* 构造方法。
|
||||
*
|
||||
* @param MerchantPortalChannelQueryService $queryService 查询服务
|
||||
* @param MerchantPortalChannelCommandService $commandService 命令服务
|
||||
* @param MerchantPortalRoutePreviewService $routePreviewService 路由解析服务
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantPortalChannelQueryService $queryService,
|
||||
protected MerchantPortalChannelCommandService $commandService,
|
||||
protected MerchantPortalRoutePreviewService $routePreviewService
|
||||
) {
|
||||
}
|
||||
@@ -51,5 +54,54 @@ class MerchantPortalChannelService extends BaseService
|
||||
{
|
||||
return $this->routePreviewService->routePreview($merchantId, $payTypeId, $payAmount, $statDate);
|
||||
}
|
||||
}
|
||||
|
||||
public function createMeta(): array
|
||||
{
|
||||
return $this->commandService->createMeta();
|
||||
}
|
||||
|
||||
public function pluginConfigs(array $filters, int $merchantId, int $page, int $pageSize): array
|
||||
{
|
||||
return $this->commandService->pluginConfigs($filters, $merchantId, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function createPluginConfig(int $merchantId, array $data)
|
||||
{
|
||||
return $this->commandService->createPluginConfig($merchantId, $data);
|
||||
}
|
||||
|
||||
public function updatePluginConfig(int $merchantId, int $id, array $data)
|
||||
{
|
||||
return $this->commandService->updatePluginConfig($merchantId, $id, $data);
|
||||
}
|
||||
|
||||
public function deletePluginConfig(int $merchantId, int $id): bool
|
||||
{
|
||||
return $this->commandService->deletePluginConfig($merchantId, $id);
|
||||
}
|
||||
|
||||
public function pluginConfigOptions(int $merchantId, string $pluginCode = ''): array
|
||||
{
|
||||
return $this->commandService->pluginConfigOptions($merchantId, $pluginCode);
|
||||
}
|
||||
|
||||
public function createChannel(int $merchantId, array $data)
|
||||
{
|
||||
return $this->commandService->createChannel($merchantId, $data);
|
||||
}
|
||||
|
||||
public function updateChannel(int $merchantId, int $id, array $data)
|
||||
{
|
||||
return $this->commandService->updateChannel($merchantId, $id, $data);
|
||||
}
|
||||
|
||||
public function deleteChannel(int $merchantId, int $id): bool
|
||||
{
|
||||
return $this->commandService->deleteChannel($merchantId, $id);
|
||||
}
|
||||
|
||||
public function pluginSchema(string $pluginCode): array
|
||||
{
|
||||
return $this->commandService->pluginSchema($pluginCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,22 +33,44 @@ class MerchantPortalCredentialCommandService extends BaseService
|
||||
* 生成或重置商户 API 凭证。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param array $options 生成选项
|
||||
* @return array 凭证数据
|
||||
*/
|
||||
public function issueCredential(int $merchantId): array
|
||||
public function issueCredential(int $merchantId, array $options = []): array
|
||||
{
|
||||
$merchant = $this->supportService->merchantSummary($merchantId);
|
||||
$credentialValue = $this->merchantApiCredentialService->issueCredential($merchantId);
|
||||
$result = $this->merchantApiCredentialService->issueCredentialBundle($merchantId, $options);
|
||||
$credentialValue = (string) ($result['credential_value'] ?? '');
|
||||
$merchantPrivateKey = (string) ($result['merchant_private_key'] ?? '');
|
||||
$generated = (array) ($result['generated'] ?? []);
|
||||
// 凭证明文只在发放当次返回一次,随后再查库只拿脱敏后的展示结构。
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'merchant' => $this->formatMerchant($merchant),
|
||||
'integration' => $this->supportService->apiIntegrationInfo($merchant),
|
||||
'credential_value' => $credentialValue,
|
||||
'merchant_private_key' => $merchantPrivateKey,
|
||||
'generated' => $generated,
|
||||
'credential' => $credential ? $this->formatCredential($credential, $merchant) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化页面所需商户摘要。
|
||||
*
|
||||
* @param array $merchant 商户摘要
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatMerchant(array $merchant): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($merchant['id'] ?? $merchant['merchant_id'] ?? 0),
|
||||
'merchant_no' => (string) ($merchant['merchant_no'] ?? ''),
|
||||
'merchant_name' => (string) ($merchant['merchant_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化接口凭证展示数据。
|
||||
*
|
||||
@@ -58,22 +80,25 @@ class MerchantPortalCredentialCommandService extends BaseService
|
||||
*/
|
||||
private function formatCredential(\app\model\merchant\MerchantApiCredential $credential, array $merchant): array
|
||||
{
|
||||
$signType = (int) $credential->sign_type;
|
||||
$apiKey = trim((string) $credential->api_key);
|
||||
$merchantPublicKey = trim((string) ($credential->merchant_public_key ?? ''));
|
||||
$platformPublicKey = trim((string) config('epay.v2.platform_public_key', ''));
|
||||
|
||||
return [
|
||||
'id' => (int) $credential->id,
|
||||
'merchant_id' => (int) $credential->merchant_id,
|
||||
'merchant_no' => (string) ($merchant['merchant_no'] ?? ''),
|
||||
'merchant_name' => (string) ($merchant['merchant_name'] ?? ''),
|
||||
'sign_type' => $signType,
|
||||
'sign_type_text' => $this->supportService->signTypeText($signType),
|
||||
// 展示页只保留脱敏后的 key 片段,避免明文凭证再次暴露。
|
||||
'api_key_preview' => $this->maskCredentialValue((string) $credential->api_key),
|
||||
'api_key_preview' => $this->maskCredentialValue($apiKey),
|
||||
'api_key_full' => $apiKey,
|
||||
'merchant_public_key_full' => $merchantPublicKey,
|
||||
'merchant_public_key_preview' => $this->maskCredentialValue($merchantPublicKey),
|
||||
'platform_public_key_full' => $platformPublicKey,
|
||||
'platform_public_key_preview' => $this->maskCredentialValue($platformPublicKey),
|
||||
'supports_v1' => $apiKey !== '',
|
||||
'supports_v2' => $merchantPublicKey !== '' && $platformPublicKey !== '',
|
||||
'v1_status_text' => $apiKey !== '' ? '已配置' : '未配置',
|
||||
'v2_status_text' => $merchantPublicKey !== '' && $platformPublicKey !== '' ? '已配置' : '未配置',
|
||||
'status' => (int) $credential->status,
|
||||
'status_text' => (string) ($credential->status ? '启用' : '禁用'),
|
||||
'last_used_at' => $this->formatDateTime($credential->last_used_at ?? null),
|
||||
'created_at' => $this->formatDateTime($credential->created_at ?? null),
|
||||
'updated_at' => $this->formatDateTime($credential->updated_at ?? null),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\model\merchant\MerchantApiCredential;
|
||||
use app\repository\merchant\credential\MerchantApiCredentialRepository;
|
||||
|
||||
@@ -39,12 +39,28 @@ class MerchantPortalCredentialQueryService extends BaseService
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'merchant' => $this->formatMerchant($merchant),
|
||||
'has_credential' => $credential !== null,
|
||||
'integration' => $this->supportService->apiIntegrationInfo($merchant),
|
||||
'credential' => $credential ? $this->formatCredential($credential, $merchant) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化页面所需商户摘要。
|
||||
*
|
||||
* @param array $merchant 商户摘要
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatMerchant(array $merchant): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($merchant['id'] ?? $merchant['merchant_id'] ?? 0),
|
||||
'merchant_no' => (string) ($merchant['merchant_no'] ?? ''),
|
||||
'merchant_name' => (string) ($merchant['merchant_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化接口凭证展示数据。
|
||||
*
|
||||
@@ -54,24 +70,26 @@ class MerchantPortalCredentialQueryService extends BaseService
|
||||
*/
|
||||
private function formatCredential(MerchantApiCredential $credential, array $merchant): array
|
||||
{
|
||||
$signType = (int) $credential->sign_type;
|
||||
$status = (int) $credential->status;
|
||||
$apiKey = trim((string) $credential->api_key);
|
||||
$merchantPublicKey = trim((string) ($credential->merchant_public_key ?? ''));
|
||||
$platformPublicKey = trim((string) config('epay.v2.platform_public_key', ''));
|
||||
|
||||
return [
|
||||
'id' => (int) $credential->id,
|
||||
'merchant_id' => (int) $credential->merchant_id,
|
||||
'merchant_no' => (string) ($merchant['merchant_no'] ?? ''),
|
||||
'merchant_name' => (string) ($merchant['merchant_name'] ?? ''),
|
||||
'sign_type' => $signType,
|
||||
'sign_type_text' => $this->supportService->signTypeText($signType),
|
||||
'api_key_preview' => $this->maskCredentialValue((string) $credential->api_key),
|
||||
'api_key_preview' => $this->maskCredentialValue($apiKey),
|
||||
'api_key_full' => $apiKey,
|
||||
'merchant_public_key_full' => $merchantPublicKey,
|
||||
'merchant_public_key_preview' => $this->maskCredentialValue($merchantPublicKey),
|
||||
'platform_public_key_full' => $platformPublicKey,
|
||||
'platform_public_key_preview' => $this->maskCredentialValue($platformPublicKey),
|
||||
'supports_v1' => $apiKey !== '',
|
||||
'supports_v2' => $merchantPublicKey !== '' && $platformPublicKey !== '',
|
||||
'v1_status_text' => $apiKey !== '' ? '已配置' : '未配置',
|
||||
'v2_status_text' => $merchantPublicKey !== '' && $platformPublicKey !== '' ? '已配置' : '未配置',
|
||||
'status' => $status,
|
||||
'status_text' => (string) (CommonConstant::statusMap()[$status] ?? '未知'),
|
||||
'last_used_at' => $this->formatDateTime($credential->last_used_at ?? null),
|
||||
'created_at' => $this->formatDateTime($credential->created_at ?? null),
|
||||
'updated_at' => $this->formatDateTime($credential->updated_at ?? null),
|
||||
'status_text' => $this->textFromMap($status, AuthConstant::credentialStatusMap()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -39,11 +39,11 @@ class MerchantPortalCredentialService extends BaseService
|
||||
* 生成或重置商户 API 凭证。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param array $options 生成选项
|
||||
* @return array 凭证数据
|
||||
*/
|
||||
public function issueCredential(int $merchantId): array
|
||||
public function issueCredential(int $merchantId, array $options = []): array
|
||||
{
|
||||
return $this->commandService->issueCredential($merchantId);
|
||||
return $this->commandService->issueCredential($merchantId, $options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ class MerchantPortalRoutePreviewService extends BaseService
|
||||
));
|
||||
} catch (Throwable $e) {
|
||||
// 解析异常只影响路由结果,不影响基础信息展示,因此这里只回填失败原因。
|
||||
$response['reason'] = $e->getMessage() !== '' ? $e->getMessage() : '路由解析失败';
|
||||
$response['reason'] = $e->getMessage();
|
||||
}
|
||||
|
||||
return $response;
|
||||
|
||||
@@ -79,6 +79,56 @@ class MerchantPortalService extends BaseService
|
||||
return $this->channelService->myChannels($filters, $merchantId, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function channelCreateMeta(): array
|
||||
{
|
||||
return $this->channelService->createMeta();
|
||||
}
|
||||
|
||||
public function pluginConfigs(array $filters, int $merchantId, int $page, int $pageSize): array
|
||||
{
|
||||
return $this->channelService->pluginConfigs($filters, $merchantId, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function createPluginConfig(int $merchantId, array $data)
|
||||
{
|
||||
return $this->channelService->createPluginConfig($merchantId, $data);
|
||||
}
|
||||
|
||||
public function updatePluginConfig(int $merchantId, int $id, array $data)
|
||||
{
|
||||
return $this->channelService->updatePluginConfig($merchantId, $id, $data);
|
||||
}
|
||||
|
||||
public function deletePluginConfig(int $merchantId, int $id): bool
|
||||
{
|
||||
return $this->channelService->deletePluginConfig($merchantId, $id);
|
||||
}
|
||||
|
||||
public function pluginConfigOptions(int $merchantId, string $pluginCode = ''): array
|
||||
{
|
||||
return $this->channelService->pluginConfigOptions($merchantId, $pluginCode);
|
||||
}
|
||||
|
||||
public function createChannel(int $merchantId, array $data)
|
||||
{
|
||||
return $this->channelService->createChannel($merchantId, $data);
|
||||
}
|
||||
|
||||
public function updateChannel(int $merchantId, int $id, array $data)
|
||||
{
|
||||
return $this->channelService->updateChannel($merchantId, $id, $data);
|
||||
}
|
||||
|
||||
public function deleteChannel(int $merchantId, int $id): bool
|
||||
{
|
||||
return $this->channelService->deleteChannel($merchantId, $id);
|
||||
}
|
||||
|
||||
public function pluginSchema(string $pluginCode): array
|
||||
{
|
||||
return $this->channelService->pluginSchema($pluginCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商户路由解析结果。
|
||||
*
|
||||
@@ -108,11 +158,12 @@ class MerchantPortalService extends BaseService
|
||||
* 生成或重置商户门户接口凭证。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param array $options 生成选项
|
||||
* @return array 凭证数据
|
||||
*/
|
||||
public function issueCredential(int $merchantId): array
|
||||
public function issueCredential(int $merchantId, array $options = []): array
|
||||
{
|
||||
return $this->credentialService->issueCredential($merchantId);
|
||||
return $this->credentialService->issueCredential($merchantId, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -127,7 +127,31 @@ class MerchantPortalSupportService extends BaseService
|
||||
*/
|
||||
public function enabledPayTypeOptions(): array
|
||||
{
|
||||
return $this->paymentTypeService->enabledOptions();
|
||||
return array_values(array_filter(
|
||||
$this->paymentTypeService->enabledOptions(),
|
||||
static function (array $option): bool {
|
||||
$label = trim((string) ($option['label'] ?? ''));
|
||||
$value = trim((string) ($option['value'] ?? ''));
|
||||
|
||||
return $label !== '' && $label !== $value;
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户开放接口对接信息。
|
||||
*
|
||||
* @param array $merchant 商户摘要
|
||||
* @return array<string, mixed> 对接信息
|
||||
*/
|
||||
public function apiIntegrationInfo(array $merchant): array
|
||||
{
|
||||
$baseUrl = rtrim((string) sys_config('site_url', ''), '/');
|
||||
|
||||
return [
|
||||
'base_url' => $baseUrl,
|
||||
'merchant_id' => (int) ($merchant['merchant_id'] ?? $merchant['id'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,7 +63,12 @@ class MerchantApiCredentialQueryService extends BaseService
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
$row->sign_type_text = $this->textFromMap((int) $row->sign_type, AuthConstant::signTypeMap());
|
||||
$row->status_text = (int) $row->status === AuthConstant::LOGIN_STATUS_ENABLED ? '启用' : '禁用';
|
||||
$row->status_text = $this->textFromMap((int) $row->status, AuthConstant::credentialStatusMap());
|
||||
$row->platform_public_key_preview = $this->maskCredentialValue(
|
||||
trim((string) config('epay.v2.platform_public_key', '')),
|
||||
false
|
||||
);
|
||||
$row->platform_sign_type_text = (string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA);
|
||||
|
||||
return $row;
|
||||
});
|
||||
@@ -110,6 +115,7 @@ class MerchantApiCredentialQueryService extends BaseService
|
||||
'c.id',
|
||||
'c.merchant_id',
|
||||
'c.sign_type',
|
||||
'c.merchant_public_key',
|
||||
'c.status',
|
||||
'c.last_used_at',
|
||||
'c.created_at',
|
||||
@@ -120,9 +126,12 @@ class MerchantApiCredentialQueryService extends BaseService
|
||||
|
||||
if ($maskCredentialValue) {
|
||||
$query->selectRaw("CASE WHEN c.api_key IS NULL OR c.api_key = '' THEN '' ELSE CONCAT(LEFT(c.api_key, 4), '****', RIGHT(c.api_key, 4)) END AS api_key_preview");
|
||||
$query->selectRaw("CASE WHEN c.merchant_public_key IS NULL OR c.merchant_public_key = '' THEN '' ELSE CONCAT(LEFT(c.merchant_public_key, 12), '****', RIGHT(c.merchant_public_key, 12)) END AS merchant_public_key_preview");
|
||||
} else {
|
||||
$query->addSelect('c.api_key');
|
||||
$query->addSelect('c.merchant_public_key');
|
||||
$query->selectRaw("COALESCE(c.api_key, '') AS api_key_full");
|
||||
$query->selectRaw("COALESCE(c.merchant_public_key, '') AS merchant_public_key_full");
|
||||
}
|
||||
|
||||
return $query;
|
||||
@@ -141,8 +150,12 @@ class MerchantApiCredentialQueryService extends BaseService
|
||||
}
|
||||
|
||||
$row->api_key_preview = $this->maskCredentialValue((string) ($row->api_key ?? ''), false);
|
||||
$row->merchant_public_key_preview = $this->maskCredentialValue((string) ($row->merchant_public_key ?? ''), false);
|
||||
$row->sign_type_text = $this->textFromMap((int) $row->sign_type, AuthConstant::signTypeMap());
|
||||
$row->status_text = (int) $row->status === AuthConstant::LOGIN_STATUS_ENABLED ? '启用' : '禁用';
|
||||
$row->status_text = $this->textFromMap((int) $row->status, AuthConstant::credentialStatusMap());
|
||||
$row->platform_public_key_full = trim((string) config('epay.v2.platform_public_key', ''));
|
||||
$row->platform_public_key_preview = $this->maskCredentialValue((string) $row->platform_public_key_full, false);
|
||||
$row->platform_sign_type_text = (string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace app\service\merchant\security;
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\util\RsaKeyPairGenerator;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\merchant\Merchant;
|
||||
@@ -13,9 +14,9 @@ use app\repository\merchant\credential\MerchantApiCredentialRepository;
|
||||
use app\repository\merchant\base\MerchantRepository;
|
||||
|
||||
/**
|
||||
* 商户对外接口凭证与签名校验服务。
|
||||
* 商户对外接口凭证服务。
|
||||
*
|
||||
* 负责商户外部接口签名校验、接口凭证发放和最近使用时间更新。
|
||||
* 负责接口凭证发放、查询和最近使用时间更新。
|
||||
*
|
||||
* @property MerchantRepository $merchantRepository 商户仓库
|
||||
* @property MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
|
||||
@@ -51,82 +52,7 @@ class MerchantApiCredentialService extends BaseService
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验外部支付接口的 MD5 签名。
|
||||
*
|
||||
* 会先校验商户和接口凭证是否存在,再按签名规则计算并比对请求签名。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array{merchant: Merchant, credential: MerchantApiCredential} 校验通过后的商户和凭证数据
|
||||
* @throws ValidationException
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
public function verifyMd5Sign(array $payload): array
|
||||
{
|
||||
$merchantId = (int) ($payload['pid'] ?? $payload['merchant_id'] ?? 0);
|
||||
$sign = trim((string) ($payload['sign'] ?? ''));
|
||||
$signType = strtoupper((string) ($payload['sign_type'] ?? 'MD5'));
|
||||
$providedKey = trim((string) ($payload['key'] ?? ''));
|
||||
|
||||
if ($merchantId <= 0 || $sign === '') {
|
||||
throw new ValidationException('pid/sign 参数缺失');
|
||||
}
|
||||
|
||||
if ($signType !== 'MD5') {
|
||||
throw new ValidationException('仅支持 MD5 签名');
|
||||
}
|
||||
|
||||
/** @var Merchant|null $merchant */
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
/** @var MerchantApiCredential|null $credential */
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
if (!$credential || (int) $credential->status !== AuthConstant::LOGIN_STATUS_ENABLED) {
|
||||
throw new ValidationException('商户 API 凭证未开通');
|
||||
}
|
||||
|
||||
if ($providedKey !== '' && !hash_equals((string) $credential->api_key, $providedKey)) {
|
||||
throw new ValidationException('商户 API 凭证错误');
|
||||
}
|
||||
|
||||
// 签名字段本身不参与原文拼接,只保留业务参数。
|
||||
$params = $payload;
|
||||
unset($params['sign'], $params['sign_type'], $params['key']);
|
||||
// 过滤空值并按键名排序,保证不同参数顺序下得到同一签名串。
|
||||
foreach ($params as $paramKey => $paramValue) {
|
||||
if ($paramValue === '' || $paramValue === null) {
|
||||
unset($params[$paramKey]);
|
||||
}
|
||||
}
|
||||
ksort($params);
|
||||
|
||||
$key = (string) $credential->api_key;
|
||||
$query = [];
|
||||
// 旧版 ePay 采用 `a=1&b=2` 再拼接 key 的方式验签,这里保持兼容。
|
||||
foreach ($params as $paramKey => $paramValue) {
|
||||
$query[] = $paramKey . '=' . $paramValue;
|
||||
}
|
||||
$base = implode('&', $query) . $key;
|
||||
$expected = md5($base);
|
||||
|
||||
// 使用常量时间比较,避免签名对比被时序差异放大。
|
||||
if (!hash_equals(strtolower($expected), strtolower($sign))) {
|
||||
throw new ValidationException('签名验证失败');
|
||||
}
|
||||
|
||||
$credential->last_used_at = $this->now();
|
||||
$credential->save();
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'credential' => $credential,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 为商户生成并保存一份新的接口凭证。
|
||||
* 为商户生成并保存一份新的 V1 接口凭证。
|
||||
*
|
||||
* 返回值是明文接口凭证值,只会在调用时完整出现一次,后续仅保存脱敏展示。
|
||||
*
|
||||
@@ -135,24 +61,77 @@ class MerchantApiCredentialService extends BaseService
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
public function issueCredential(int $merchantId): string
|
||||
{
|
||||
$result = $this->issueCredentialBundle($merchantId, [
|
||||
'rotate_v1' => true,
|
||||
'rotate_v2' => false,
|
||||
]);
|
||||
|
||||
return (string) ($result['credential_value'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 为商户生成一组接口凭证。
|
||||
*
|
||||
* 该方法可同时重置 V1 API Key 和 V2 RSA 密钥对,适合管理后台的自动生成场景。
|
||||
* 生成后的私钥只在返回结果里出现一次,不会落库。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param array<string, mixed> $options 生成选项
|
||||
* @return array<string, mixed> 凭证数据和生成结果
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function issueCredentialBundle(int $merchantId, array $options = []): array
|
||||
{
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
$credentialValue = $this->generateCredentialValue();
|
||||
$this->merchantApiCredentialRepository->updateOrCreate(
|
||||
$current = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
$rotateV1 = array_key_exists('rotate_v1', $options) ? (bool) $options['rotate_v1'] : true;
|
||||
$rotateV2 = array_key_exists('rotate_v2', $options) ? (bool) $options['rotate_v2'] : true;
|
||||
if (!$rotateV1 && !$rotateV2) {
|
||||
throw new ValidationException('请至少选择一种要生成的凭证类型');
|
||||
}
|
||||
|
||||
$signType = (int) ($options['sign_type'] ?? ($current?->sign_type ?? AuthConstant::API_SIGN_TYPE_MD5));
|
||||
$status = (int) ($options['status'] ?? ($current?->status ?? AuthConstant::CREDENTIAL_STATUS_ENABLED));
|
||||
$credentialValue = $rotateV1 ? $this->generateCredentialValue() : trim((string) ($current?->api_key ?? ''));
|
||||
$merchantPrivateKey = '';
|
||||
$merchantPublicKey = trim((string) ($current?->merchant_public_key ?? ''));
|
||||
|
||||
if ($rotateV2) {
|
||||
$pair = RsaKeyPairGenerator::generate();
|
||||
$merchantPrivateKey = $pair['private_key'];
|
||||
$merchantPublicKey = $pair['public_key'];
|
||||
}
|
||||
|
||||
$credential = $this->merchantApiCredentialRepository->updateOrCreate(
|
||||
['merchant_id' => $merchantId],
|
||||
[
|
||||
'merchant_id' => $merchantId,
|
||||
'sign_type' => AuthConstant::API_SIGN_TYPE_MD5,
|
||||
'sign_type' => $signType,
|
||||
'status' => $status,
|
||||
'api_key' => $credentialValue,
|
||||
'status' => AuthConstant::LOGIN_STATUS_ENABLED,
|
||||
'merchant_public_key' => $merchantPublicKey,
|
||||
]
|
||||
);
|
||||
|
||||
return $credentialValue;
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'credential' => $credential,
|
||||
'credential_value' => $credentialValue,
|
||||
'merchant_private_key' => $merchantPrivateKey,
|
||||
'generated' => [
|
||||
'rotate_v1' => $rotateV1,
|
||||
'rotate_v2' => $rotateV2,
|
||||
'api_key' => $rotateV1 ? $credentialValue : '',
|
||||
'merchant_private_key' => $merchantPrivateKey,
|
||||
'merchant_public_key' => $merchantPublicKey,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,6 +182,12 @@ class MerchantApiCredentialService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
$apiKey = trim((string) ($data['api_key'] ?? ''));
|
||||
$merchantPublicKey = trim((string) ($data['merchant_public_key'] ?? ''));
|
||||
if ($apiKey === '' && $merchantPublicKey === '') {
|
||||
throw new ValidationException('请至少填写 V1 API Key 或 V2 商户 RSA 公钥');
|
||||
}
|
||||
|
||||
return $this->merchantApiCredentialRepository->create($this->normalizePayload($data, false));
|
||||
}
|
||||
|
||||
@@ -264,7 +249,7 @@ class MerchantApiCredentialService extends BaseService
|
||||
|
||||
/** @var MerchantApiCredential|null $credential */
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
if (!$credential || (int) $credential->status !== AuthConstant::LOGIN_STATUS_ENABLED) {
|
||||
if (!$credential || (int) $credential->status !== AuthConstant::CREDENTIAL_STATUS_ENABLED) {
|
||||
throw new ValidationException('商户 API 凭证未开通');
|
||||
}
|
||||
|
||||
@@ -288,24 +273,33 @@ class MerchantApiCredentialService extends BaseService
|
||||
* @param array $data 凭证数据
|
||||
* @param bool $isUpdate 是否更新
|
||||
* @param MerchantApiCredential|null $current 当前凭证
|
||||
* 更新场景下,空字符串视为“不修改”,避免手动配置时误清空已有密钥。
|
||||
* `sign_type` 在当前阶段只作为展示/默认接入说明,不再作为 V1/V2 互斥开关。
|
||||
*
|
||||
* @return array{merchant_id: int, sign_type: int, status: int, api_key?: string} 标准化后的写入数据
|
||||
*/
|
||||
private function normalizePayload(array $data, bool $isUpdate, ?MerchantApiCredential $current = null): array
|
||||
{
|
||||
// 更新场景下以现有记录的 merchant_id 为准,避免把凭证误挂到别的商户。
|
||||
$merchantId = (int) ($current?->merchant_id ?? ($data['merchant_id'] ?? 0));
|
||||
$currentSignType = (int) ($current?->sign_type ?? AuthConstant::API_SIGN_TYPE_MD5);
|
||||
$currentStatus = (int) ($current?->status ?? AuthConstant::CREDENTIAL_STATUS_ENABLED);
|
||||
$payload = [
|
||||
'merchant_id' => $merchantId,
|
||||
'sign_type' => (int) ($data['sign_type'] ?? AuthConstant::API_SIGN_TYPE_MD5),
|
||||
'status' => (int) ($data['status'] ?? AuthConstant::LOGIN_STATUS_ENABLED),
|
||||
'sign_type' => (int) ($data['sign_type'] ?? $currentSignType),
|
||||
'status' => (int) ($data['status'] ?? $currentStatus),
|
||||
];
|
||||
|
||||
$apiKey = trim((string) ($data['api_key'] ?? ''));
|
||||
if ($apiKey !== '') {
|
||||
$payload['api_key'] = $apiKey;
|
||||
} elseif (!$isUpdate) {
|
||||
// 新增凭证时如果前端没有传入明文 key,就自动补一份随机值。
|
||||
$payload['api_key'] = $this->generateCredentialValue();
|
||||
}
|
||||
|
||||
if (array_key_exists('merchant_public_key', $data)) {
|
||||
$merchantPublicKey = trim((string) ($data['merchant_public_key'] ?? ''));
|
||||
if ($merchantPublicKey !== '' || !$isUpdate) {
|
||||
$payload['merchant_public_key'] = $merchantPublicKey;
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
|
||||
@@ -48,8 +48,8 @@ class ChannelNotifyLogService extends BaseService
|
||||
->orWhere('n.channel_request_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('n.channel_trade_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('n.last_error', 'like', '%' . $keyword . '%')
|
||||
->orWhere('p.merchant_order_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('p.subject', 'like', '%' . $keyword . '%')
|
||||
->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('bo.subject', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%')
|
||||
@@ -107,7 +107,7 @@ class ChannelNotifyLogService extends BaseService
|
||||
->where('n.id', $id)
|
||||
->first();
|
||||
|
||||
return $row ?: null;
|
||||
return $row ? $this->decorateRow($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,6 +139,7 @@ class ChannelNotifyLogService extends BaseService
|
||||
return $this->channelNotifyLogRepository->query()
|
||||
->from('ma_channel_notify_log as n')
|
||||
->leftJoin('ma_pay_order as p', 'p.pay_no', '=', 'n.pay_no')
|
||||
->leftJoin('ma_biz_order as bo', 'bo.biz_no', '=', 'n.biz_no')
|
||||
->leftJoin('ma_merchant as m', 'm.id', '=', 'p.merchant_id')
|
||||
->leftJoin('ma_merchant_group as g', 'g.id', '=', 'm.group_id')
|
||||
->leftJoin('ma_payment_channel as c', 'c.id', '=', 'n.channel_id')
|
||||
@@ -160,8 +161,8 @@ class ChannelNotifyLogService extends BaseService
|
||||
'n.created_at',
|
||||
'n.updated_at',
|
||||
'p.merchant_id',
|
||||
'p.merchant_order_no',
|
||||
'p.subject',
|
||||
'bo.merchant_order_no',
|
||||
'bo.subject',
|
||||
])
|
||||
->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no")
|
||||
->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name")
|
||||
@@ -173,5 +174,3 @@ class ChannelNotifyLogService extends BaseService
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
161
app/service/ops/log/MerchantNotifyTaskService.php
Normal file
161
app/service/ops/log/MerchantNotifyTaskService.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\ops\log;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\NotifyConstant;
|
||||
use app\model\payment\NotifyTask;
|
||||
use app\repository\payment\notify\NotifyTaskRepository;
|
||||
use app\service\payment\runtime\MerchantNotifyDispatcherService;
|
||||
|
||||
/**
|
||||
* 商户通知任务查询服务。
|
||||
*
|
||||
* 负责后台查询通知任务、格式化展示字段以及手动重试。
|
||||
*/
|
||||
class MerchantNotifyTaskService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected NotifyTaskRepository $notifyTaskRepository,
|
||||
protected MerchantNotifyDispatcherService $merchantNotifyDispatcherService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询商户通知任务。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->baseQuery();
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('n.notify_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('n.event_type', 'like', '%' . $keyword . '%')
|
||||
->orWhere('n.ref_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('n.biz_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('n.pay_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('n.notify_url', 'like', '%' . $keyword . '%')
|
||||
->orWhere('n.last_response', 'like', '%' . $keyword . '%')
|
||||
->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('bo.subject', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('g.group_name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$merchantId = (string) ($filters['merchant_id'] ?? '');
|
||||
if ($merchantId !== '') {
|
||||
$query->where('n.merchant_id', (int) $merchantId);
|
||||
}
|
||||
|
||||
$status = (string) ($filters['status'] ?? '');
|
||||
if ($status !== '') {
|
||||
$query->where('n.status', (int) $status);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderByDesc('n.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
return $this->decorateRow($row);
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户通知任务详情。
|
||||
*
|
||||
* @param string $notifyNo 通知号
|
||||
* @return NotifyTask|object|null
|
||||
*/
|
||||
public function findByNotifyNo(string $notifyNo): mixed
|
||||
{
|
||||
$row = $this->baseQuery()
|
||||
->where('n.notify_no', $notifyNo)
|
||||
->first();
|
||||
|
||||
return $row ? $this->decorateRow($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动重试通知任务。
|
||||
*
|
||||
* @param string $notifyNo 通知号
|
||||
* @return mixed
|
||||
*/
|
||||
public function retry(string $notifyNo): mixed
|
||||
{
|
||||
$this->merchantNotifyDispatcherService->dispatchTask($notifyNo);
|
||||
|
||||
return $this->findByNotifyNo($notifyNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化单条记录。
|
||||
*
|
||||
* @param object $row 原始查询对象
|
||||
* @return object
|
||||
*/
|
||||
private function decorateRow(object $row): object
|
||||
{
|
||||
$row->event_type_text = (string) (NotifyConstant::eventTypeMap()[(string) $row->event_type] ?? (string) $row->event_type);
|
||||
$row->status_text = (string) (NotifyConstant::taskStatusMap()[(int) $row->status] ?? '未知');
|
||||
$row->notify_data_text = $this->formatJson($row->notify_data ?? null);
|
||||
$row->last_notify_at_text = $this->formatDateTime($row->last_notify_at ?? null);
|
||||
$row->next_retry_at_text = $this->formatDateTime($row->next_retry_at ?? null);
|
||||
$row->created_at_text = $this->formatDateTime($row->created_at ?? null);
|
||||
$row->updated_at_text = $this->formatDateTime($row->updated_at ?? null);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建基础查询。
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
private function baseQuery()
|
||||
{
|
||||
return $this->notifyTaskRepository->query()
|
||||
->from('ma_notify_task as n')
|
||||
->leftJoin('ma_merchant as m', 'm.id', '=', 'n.merchant_id')
|
||||
->leftJoin('ma_merchant_group as g', 'g.id', '=', 'n.merchant_group_id')
|
||||
->leftJoin('ma_biz_order as bo', 'bo.biz_no', '=', 'n.biz_no')
|
||||
->select([
|
||||
'n.id',
|
||||
'n.notify_no',
|
||||
'n.event_type',
|
||||
'n.ref_no',
|
||||
'n.merchant_id',
|
||||
'n.merchant_group_id',
|
||||
'n.biz_no',
|
||||
'n.pay_no',
|
||||
'n.notify_url',
|
||||
'n.notify_data',
|
||||
'n.status',
|
||||
'n.retry_count',
|
||||
'n.next_retry_at',
|
||||
'n.last_notify_at',
|
||||
'n.last_response',
|
||||
'n.created_at',
|
||||
'n.updated_at',
|
||||
'bo.merchant_order_no',
|
||||
'bo.subject',
|
||||
])
|
||||
->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no")
|
||||
->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name")
|
||||
->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name")
|
||||
->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name");
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,8 @@ class PayCallbackLogService extends BaseService
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('l.pay_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('p.merchant_order_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('p.subject', 'like', '%' . $keyword . '%')
|
||||
->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('bo.subject', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%')
|
||||
@@ -102,7 +102,7 @@ class PayCallbackLogService extends BaseService
|
||||
->where('l.id', $id)
|
||||
->first();
|
||||
|
||||
return $row ?: null;
|
||||
return $row ? $this->decorateRow($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,6 +133,7 @@ class PayCallbackLogService extends BaseService
|
||||
return $this->payCallbackLogRepository->query()
|
||||
->from('ma_pay_callback_log as l')
|
||||
->leftJoin('ma_pay_order as p', 'p.pay_no', '=', 'l.pay_no')
|
||||
->leftJoin('ma_biz_order as bo', 'bo.biz_no', '=', 'p.biz_no')
|
||||
->leftJoin('ma_merchant as m', 'm.id', '=', 'p.merchant_id')
|
||||
->leftJoin('ma_merchant_group as g', 'g.id', '=', 'm.group_id')
|
||||
->leftJoin('ma_payment_channel as c', 'c.id', '=', 'l.channel_id')
|
||||
@@ -142,13 +143,14 @@ class PayCallbackLogService extends BaseService
|
||||
'l.channel_id',
|
||||
'l.callback_type',
|
||||
'l.request_data',
|
||||
'l.request_hash',
|
||||
'l.verify_status',
|
||||
'l.process_status',
|
||||
'l.process_result',
|
||||
'l.created_at',
|
||||
'p.merchant_id',
|
||||
'p.merchant_order_no',
|
||||
'p.subject',
|
||||
'bo.merchant_order_no',
|
||||
'bo.subject',
|
||||
])
|
||||
->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no")
|
||||
->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name")
|
||||
@@ -160,5 +162,3 @@ class PayCallbackLogService extends BaseService
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
293
app/service/payment/cashier/CashierService.php
Normal file
293
app/service/payment/cashier/CashierService.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\cashier;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
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\repository\payment\trade\BizOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\service\merchant\MerchantService;
|
||||
use app\service\payment\config\PaymentTypeService;
|
||||
use app\service\payment\order\PayOrderService;
|
||||
use app\service\payment\order\PaymentOrderInputAssembler;
|
||||
use app\service\payment\runtime\PaymentRouteService;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 收银台服务。
|
||||
*
|
||||
* 负责收银台上下文、可选支付方式和最终支付确认。
|
||||
*/
|
||||
class CashierService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantService $merchantService,
|
||||
protected PaymentTypeService $paymentTypeService,
|
||||
protected PaymentRouteService $paymentRouteService,
|
||||
protected BizOrderRepository $bizOrderRepository,
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected PayOrderService $payOrderService,
|
||||
protected PaymentOrderInputAssembler $orderInputAssembler
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询收银台上下文。
|
||||
*
|
||||
* @param string $bizNo 业务单号
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function context(string $bizNo): array
|
||||
{
|
||||
$bizNo = trim($bizNo);
|
||||
if ($bizNo === '') {
|
||||
throw new ValidationException('biz_no 不能为空');
|
||||
}
|
||||
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo($bizNo);
|
||||
if (!$bizOrder) {
|
||||
throw new ResourceNotFoundException('业务单不存在', ['biz_no' => $bizNo]);
|
||||
}
|
||||
|
||||
$merchant = $this->merchantService->ensureMerchantEnabled((int) $bizOrder->merchant_id);
|
||||
$this->merchantService->ensureMerchantGroupEnabled((int) $merchant->group_id);
|
||||
|
||||
$activePayOrder = $this->resolveActivePayOrder($bizOrder);
|
||||
$paySwitchEnabled = (int) ($merchant->pay_status ?? CommonConstant::STATUS_ENABLED) === CommonConstant::STATUS_ENABLED;
|
||||
$canPay = $paySwitchEnabled && !in_array((int) $bizOrder->status, [
|
||||
TradeConstant::ORDER_STATUS_SUCCESS,
|
||||
TradeConstant::ORDER_STATUS_CLOSED,
|
||||
TradeConstant::ORDER_STATUS_TIMEOUT,
|
||||
], true) && (!$activePayOrder || !in_array((int) $activePayOrder->status, [
|
||||
TradeConstant::ORDER_STATUS_CREATED,
|
||||
TradeConstant::ORDER_STATUS_PAYING,
|
||||
], true));
|
||||
|
||||
// 收银台首屏只做“展示 + 可选方式预览”,不在这里创建支付单。
|
||||
$availablePayTypes = $canPay
|
||||
? $this->paymentRouteService->previewAvailablePayTypes(
|
||||
(int) $merchant->group_id,
|
||||
(int) $bizOrder->order_amount,
|
||||
['stat_date' => FormatHelper::timestamp(time(), 'Y-m-d')]
|
||||
)
|
||||
: [];
|
||||
|
||||
return [
|
||||
'biz_order' => $this->formatBizOrder($bizOrder),
|
||||
'merchant' => $this->formatMerchant($merchant),
|
||||
'active_pay_order' => $activePayOrder ? $this->formatActivePayOrder($activePayOrder) : null,
|
||||
'available_pay_types' => $availablePayTypes,
|
||||
'can_pay' => $canPay,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认支付方式并创建支付单。
|
||||
*
|
||||
* @param array $input 请求参数
|
||||
* @param Request $request 请求对象
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function confirm(array $input, Request $request): array
|
||||
{
|
||||
$bizNo = trim((string) ($input['biz_no'] ?? ''));
|
||||
$typeCode = trim((string) ($input['type'] ?? ''));
|
||||
if ($bizNo === '') {
|
||||
throw new ValidationException('biz_no 不能为空');
|
||||
}
|
||||
if ($typeCode === '') {
|
||||
throw new ValidationException('type 不能为空');
|
||||
}
|
||||
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo($bizNo);
|
||||
if (!$bizOrder) {
|
||||
throw new ResourceNotFoundException('业务单不存在', ['biz_no' => $bizNo]);
|
||||
}
|
||||
|
||||
// 先恢复业务单,再把用户选中的支付方式转成一次明确的支付尝试。
|
||||
$merchant = $this->merchantService->ensureMerchantPayEnabled((int) $bizOrder->merchant_id);
|
||||
$this->merchantService->ensureMerchantGroupEnabled((int) $merchant->group_id);
|
||||
$activePayOrder = $this->resolveActivePayOrder($bizOrder);
|
||||
if ($activePayOrder && in_array((int) $activePayOrder->status, [
|
||||
TradeConstant::ORDER_STATUS_CREATED,
|
||||
TradeConstant::ORDER_STATUS_PAYING,
|
||||
], true)) {
|
||||
throw new ValidationException('当前订单已有进行中的支付尝试');
|
||||
}
|
||||
|
||||
$paymentType = $this->paymentTypeService->findByCode($typeCode);
|
||||
if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new ValidationException('支付方式不支持');
|
||||
}
|
||||
|
||||
// 收银台确认阶段只认业务单快照,避免前端再次篡改订单展示字段。
|
||||
$orderFields = $this->orderInputAssembler->buildOrderFields(
|
||||
[],
|
||||
null,
|
||||
$bizOrder,
|
||||
(array) ($bizOrder->ext_json ?? [])
|
||||
);
|
||||
|
||||
// BizOrder 作为收银台上下文的种子,PayOrder 才是真正的支付快照。
|
||||
$attempt = $this->payOrderService->preparePayAttempt([
|
||||
'merchant_id' => (int) $bizOrder->merchant_id,
|
||||
'merchant_order_no' => (string) $bizOrder->merchant_order_no,
|
||||
'pay_type_id' => (int) $paymentType->id,
|
||||
'pay_amount' => (int) $bizOrder->order_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'],
|
||||
]);
|
||||
|
||||
/** @var PayOrder $payOrder */
|
||||
$payOrder = $attempt['pay_order'];
|
||||
$payParams = (array) ($attempt['pay_params'] ?? []);
|
||||
$paymentResult = (array) ($attempt['payment_result'] ?? []);
|
||||
|
||||
return [
|
||||
'biz_no' => (string) $bizOrder->biz_no,
|
||||
'trade_no' => (string) $payOrder->pay_no,
|
||||
'pay_type' => strtolower(trim((string) ($payParams['type'] ?? $paymentResult['pay_type'] ?? 'qrcode'))),
|
||||
'pay_info' => $payParams,
|
||||
'payment_result' => $paymentResult,
|
||||
'payment_page_path' => $this->buildPaymentPagePath((string) $payOrder->pay_no),
|
||||
'payment_page_url' => $this->buildPaymentPageUrl((string) $payOrder->pay_no),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询支付页详情。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function payOrderDetail(string $payNo): array
|
||||
{
|
||||
return $this->payOrderService->detail($payNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前业务单的活跃支付单。
|
||||
*
|
||||
* @param BizOrder $bizOrder 业务单
|
||||
* @return PayOrder|null 支付单
|
||||
*/
|
||||
private function resolveActivePayOrder(BizOrder $bizOrder): ?PayOrder
|
||||
{
|
||||
$activePayNo = trim((string) ($bizOrder->active_pay_no ?? ''));
|
||||
if ($activePayNo === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->payOrderRepository->findByPayNo($activePayNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化业务单。
|
||||
*
|
||||
* @param BizOrder $bizOrder 业务单
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatBizOrder(BizOrder $bizOrder): array
|
||||
{
|
||||
$statusMap = TradeConstant::orderStatusMap();
|
||||
|
||||
return [
|
||||
'biz_no' => (string) $bizOrder->biz_no,
|
||||
'trace_no' => (string) ($bizOrder->trace_no ?? ''),
|
||||
'merchant_order_no' => (string) $bizOrder->merchant_order_no,
|
||||
'subject' => (string) $bizOrder->subject,
|
||||
'body' => (string) ($bizOrder->body ?? ''),
|
||||
'notify_url' => (string) ($bizOrder->notify_url ?? ''),
|
||||
'return_url' => (string) ($bizOrder->return_url ?? ''),
|
||||
'client_ip' => (string) ($bizOrder->client_ip ?? ''),
|
||||
'device' => (string) ($bizOrder->device ?? ''),
|
||||
'order_amount' => (int) $bizOrder->order_amount,
|
||||
'order_amount_text' => FormatHelper::amount((int) $bizOrder->order_amount),
|
||||
'paid_amount' => (int) $bizOrder->paid_amount,
|
||||
'refund_amount' => (int) $bizOrder->refund_amount,
|
||||
'status' => (int) $bizOrder->status,
|
||||
'status_text' => (string) ($statusMap[(int) $bizOrder->status] ?? ''),
|
||||
'active_pay_no' => (string) ($bizOrder->active_pay_no ?? ''),
|
||||
'attempt_count' => (int) $bizOrder->attempt_count,
|
||||
'ext_json' => (array) ($bizOrder->ext_json ?? []),
|
||||
'created_at' => FormatHelper::dateTime($bizOrder->created_at),
|
||||
'updated_at' => FormatHelper::dateTime($bizOrder->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化商户信息。
|
||||
*
|
||||
* @param Merchant $merchant 商户
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatMerchant(Merchant $merchant): array
|
||||
{
|
||||
return [
|
||||
'merchant_id' => (int) $merchant->id,
|
||||
'merchant_no' => (string) ($merchant->merchant_no ?? ''),
|
||||
'merchant_name' => (string) ($merchant->merchant_name ?? ''),
|
||||
'merchant_short_name' => (string) ($merchant->merchant_short_name ?? ''),
|
||||
'status' => (int) $merchant->status,
|
||||
'pay_status' => (int) ($merchant->pay_status ?? 1),
|
||||
'settle_status' => (int) ($merchant->settle_status ?? 1),
|
||||
'settle_type' => (int) ($merchant->settle_type ?? 4),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化活跃支付单。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付单
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatActivePayOrder(PayOrder $payOrder): array
|
||||
{
|
||||
return [
|
||||
'pay_no' => (string) $payOrder->pay_no,
|
||||
'pay_type_code' => $this->paymentTypeService->resolveCodeById((int) $payOrder->pay_type_id),
|
||||
'pay_amount' => (int) $payOrder->pay_amount,
|
||||
'pay_amount_text' => FormatHelper::amount((int) $payOrder->pay_amount),
|
||||
'status' => (int) $payOrder->status,
|
||||
'created_at' => FormatHelper::dateTime($payOrder->created_at),
|
||||
'request_at' => FormatHelper::dateTime($payOrder->request_at),
|
||||
'payment_page_path' => $this->buildPaymentPagePath((string) $payOrder->pay_no),
|
||||
'payment_page_url' => $this->buildPaymentPageUrl((string) $payOrder->pay_no),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建支付页路径。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @return string
|
||||
*/
|
||||
private function buildPaymentPagePath(string $payNo): string
|
||||
{
|
||||
return '/payment/' . rawurlencode($payNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建支付页完整地址。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @return string
|
||||
*/
|
||||
private function buildPaymentPageUrl(string $payNo): string
|
||||
{
|
||||
return rtrim((string) sys_config('site_url'), '/') . $this->buildPaymentPagePath($payNo);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -46,6 +46,7 @@ class PaymentPluginConfService extends BaseService
|
||||
->leftJoin('ma_payment_plugin as p', 'c.plugin_code', '=', 'p.code')
|
||||
->select([
|
||||
'c.id',
|
||||
'c.merchant_id',
|
||||
'c.plugin_code',
|
||||
'c.config',
|
||||
'c.settlement_cycle_type',
|
||||
@@ -71,6 +72,11 @@ class PaymentPluginConfService extends BaseService
|
||||
$query->where('c.plugin_code', $pluginCode);
|
||||
}
|
||||
|
||||
$merchantId = trim((string) ($filters['merchant_id'] ?? ''));
|
||||
if ($merchantId !== '') {
|
||||
$query->where('c.merchant_id', (int) $merchantId);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByDesc('c.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
@@ -249,6 +255,7 @@ class PaymentPluginConfService extends BaseService
|
||||
{
|
||||
return [
|
||||
'plugin_code' => trim((string) ($data['plugin_code'] ?? '')),
|
||||
'merchant_id' => max(0, (int) ($data['merchant_id'] ?? 0)),
|
||||
// 配置内容统一按数组保存,外部传入非数组时直接回退为空数组。
|
||||
'config' => is_array($data['config'] ?? null) ? $data['config'] : [],
|
||||
// 默认结算周期按日配置,截止时间默认按当天 23:59:59 收口。
|
||||
|
||||
@@ -216,6 +216,10 @@ class PaymentPluginService extends BaseService
|
||||
$payload['status'] = (int) $data['status'];
|
||||
}
|
||||
|
||||
if (array_key_exists('allow_merchant', $data)) {
|
||||
$payload['allow_merchant'] = (int) $data['allow_merchant'];
|
||||
}
|
||||
|
||||
if (array_key_exists('remark', $data)) {
|
||||
$payload['remark'] = trim((string) $data['remark']);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ class PaymentPluginSyncService extends BaseService
|
||||
$current = $existing[$code] ?? null;
|
||||
$payload = array_merge($row, [
|
||||
'status' => (int) ($current->status ?? 1),
|
||||
'allow_merchant' => (int) ($current->allow_merchant ?? 0),
|
||||
'remark' => (string) ($current->remark ?? ''),
|
||||
]);
|
||||
|
||||
@@ -141,4 +142,3 @@ class PaymentPluginSyncService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\service\payment\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\payment\PaymentType;
|
||||
use app\repository\payment\config\PaymentTypeRepository;
|
||||
@@ -87,7 +88,9 @@ class PaymentTypeService extends BaseService
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析启用中的支付方式,优先按编码匹配,未命中则取首个启用项。
|
||||
* 解析启用中的支付方式。
|
||||
*
|
||||
* 仅在显式传入编码且命中启用项时返回,未命中直接抛错,不再提供默认回退。
|
||||
*
|
||||
* @param string $code 支付方式编码
|
||||
* @return PaymentType 支付方式模型
|
||||
@@ -98,18 +101,12 @@ class PaymentTypeService extends BaseService
|
||||
$code = trim($code);
|
||||
if ($code !== '') {
|
||||
$paymentType = $this->paymentTypeRepository->findByCode($code);
|
||||
if ($paymentType && (int) $paymentType->status === 1) {
|
||||
if ($paymentType && (int) $paymentType->status === CommonConstant::STATUS_ENABLED) {
|
||||
return $paymentType;
|
||||
}
|
||||
}
|
||||
|
||||
// 没有传编码或编码不可用时,直接回退到系统当前首个启用支付方式。
|
||||
$paymentType = $this->paymentTypeRepository->enabledList()->first();
|
||||
if (!$paymentType) {
|
||||
throw new ValidationException('未配置可用支付方式');
|
||||
}
|
||||
|
||||
return $paymentType;
|
||||
throw new ValidationException('未配置可用支付方式');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,5 +181,3 @@ class PaymentTypeService extends BaseService
|
||||
return $this->paymentTypeRepository->deleteById($id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user