1. 维护代码健壮

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

View File

@@ -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;
}
/**

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

View File

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

View File

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

View File

@@ -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 => '商户后台',
];
}
}

View File

@@ -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

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

View File

@@ -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

View File

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

View File

@@ -68,4 +68,3 @@ final class MerchantConstant

View File

@@ -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 => '清算完成',
];
}
}

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

View File

@@ -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

View File

@@ -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

View 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 => '失败',
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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 : [];
}
}

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

View File

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

View File

@@ -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, '商户通知任务已执行重试');
}
}

View File

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

View File

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

View File

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

View File

@@ -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'],
];
}

View File

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

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

View File

@@ -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'],
];
}

View File

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

View File

@@ -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'],
];
}

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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',
];
}

View File

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

View File

@@ -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',
];
}

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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

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

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

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

View File

@@ -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' => '资金账户详情']);

View File

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

View File

@@ -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 凭证']);

View File

@@ -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;
}
/**
* 校验商户分组是否启用。
*

View File

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

View File

@@ -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,

View File

@@ -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) : [],
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -95,7 +95,7 @@ class MerchantPortalRoutePreviewService extends BaseService
));
} catch (Throwable $e) {
// 解析异常只影响路由结果,不影响基础信息展示,因此这里只回填失败原因。
$response['reason'] = $e->getMessage() !== '' ? $e->getMessage() : '路由解析失败';
$response['reason'] = $e->getMessage();
}
return $response;

View File

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

View File

@@ -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),
];
}
/**

View File

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

View File

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

View File

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

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

View File

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

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

View File

@@ -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 收口。

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace app\service\payment\epay;
/**
* ePay 签名器抽象基类。
*
* 负责公共签名原文拼装与 PEM 密钥归一化。
*/
abstract class EpaySignerAbstract
{
/**
* 构造待签名原文。
*
* @param array<string, mixed> $params 待签名参数
* @return string 签名原文
*/
protected function buildContent(array $params): string
{
ksort($params);
$parts = [];
foreach ($params as $key => $value) {
if ($key === 'sign' || $key === 'sign_type') {
continue;
}
if (is_array($value) || is_object($value)) {
continue;
}
if ($value === null || $value === '') {
continue;
}
$parts[] = $key . '=' . (string) $value;
}
return implode('&', $parts);
}
/**
* 归一化 PEM 密钥。
*
* @param string $key 原始密钥
* @param string $type 密钥类型
* @return string PEM 格式密钥
*/
protected function normalizePem(string $key, string $type): string
{
$key = trim($key);
if ($key === '') {
return '';
}
if (str_contains($key, 'BEGIN ')) {
return $key;
}
$type = strtoupper(trim($type));
$body = preg_replace('/\s+/', '', $key) ?? $key;
return sprintf(
"-----BEGIN %s KEY-----\n%s\n-----END %s KEY-----",
$type,
trim(chunk_split($body, 64, "\n")),
$type
);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace app\service\payment\epay;
/**
* ePay 签名器契约。
*/
interface EpaySignerInterface
{
/**
* 生成签名。
*
* @param array<string, mixed> $params 待签名参数
* @param string $key 密钥
* @return string 签名结果
*/
public function sign(array $params, string $key): string;
/**
* 验证签名。
*
* @param array<string, mixed> $params 待验签参数
* @param string $sign 签名值
* @param string $key 密钥
* @return bool 是否通过
*/
public function verify(array $params, string $sign, string $key): bool;
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace app\service\payment\epay;
use app\common\constant\AuthConstant;
use app\exception\PaymentException;
/**
* ePay 签名器管理器。
*
* 负责根据签名类型分发 MD5 与 RSA 实现。
*/
class EpaySignerManager
{
public function __construct(
private readonly Md5Signer $md5Signer,
private readonly RsaSigner $rsaSigner
) {
}
/**
* 生成签名。
*
* @param array<string, mixed> $params 待签名参数
* @param string $signType 签名类型
* @param string $key 密钥
* @return string 签名结果
*/
public function sign(array $params, string $signType, string $key): string
{
return match ($this->normalizeSignType($signType)) {
AuthConstant::API_SIGN_NAME_MD5 => $this->md5Signer->sign($params, $key),
AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA => $this->rsaSigner->sign($params, $key),
default => throw new PaymentException('不支持的签名类型', 40200),
};
}
/**
* 验证签名。
*
* @param array<string, mixed> $params 待验签参数
* @param string $signType 签名类型
* @param string $sign 签名值
* @param string $key 密钥
* @return bool 是否通过
*/
public function verify(array $params, string $signType, string $sign, string $key): bool
{
return match ($this->normalizeSignType($signType)) {
AuthConstant::API_SIGN_NAME_MD5 => $this->md5Signer->verify($params, $sign, $key),
AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA => $this->rsaSigner->verify($params, $sign, $key),
default => false,
};
}
/**
* 归一化签名类型。
*
* @param string $signType 原始签名类型
* @return string 归一化后的签名类型
*/
public function normalizeSignType(string $signType): string
{
$signType = strtoupper(trim($signType));
return match ($signType) {
'RSA' => AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA,
AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA => AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA,
AuthConstant::API_SIGN_NAME_MD5 => AuthConstant::API_SIGN_NAME_MD5,
default => $signType,
};
}
}

View File

@@ -0,0 +1,864 @@
<?php
namespace app\service\payment\epay;
use app\common\base\BaseService;
use app\common\constant\AuthConstant;
use app\common\constant\CommonConstant;
use app\common\constant\TradeConstant;
use app\common\util\FormatHelper;
use app\exception\ValidationException;
use app\model\payment\BizOrder;
use app\model\payment\PayOrder;
use app\model\payment\PaymentType;
use app\repository\account\balance\MerchantAccountRepository;
use app\repository\payment\settlement\SettlementOrderRepository;
use app\repository\payment\trade\BizOrderRepository;
use app\repository\payment\trade\PayOrderRepository;
use app\service\merchant\MerchantService;
use app\service\merchant\security\MerchantApiCredentialService;
use app\service\payment\config\PaymentTypeService;
use app\service\payment\order\PayOrderService;
use app\service\payment\order\PaymentOrderInputAssembler;
use app\service\payment\order\RefundService;
use app\service\payment\runtime\PaymentPluginManager;
use support\Request;
use support\Response;
use Throwable;
/**
* ePay V1 协议服务。
*
* 负责将旧协议请求转换为当前支付、退款和查询流程。
*
* @property MerchantApiCredentialService $merchantApiCredentialService 商户 API 凭证服务
* @property PaymentTypeService $paymentTypeService 支付类型服务
* @property PayOrderService $payOrderService 支付订单服务
* @property PaymentOrderInputAssembler $orderInputAssembler 支付入参组装器
* @property PaymentPluginManager $paymentPluginManager 支付插件管理器
* @property MerchantAccountRepository $merchantAccountRepository 商户账户仓库
* @property BizOrderRepository $bizOrderRepository 业务订单仓库
* @property PayOrderRepository $payOrderRepository 支付单仓库
* @property SettlementOrderRepository $settlementOrderRepository 结算订单仓库
* @property RefundService $refundService 退款服务
*/
class EpayV1ProtocolService extends BaseService
{
private const API_ACTIONS = ['query', 'settle', 'order', 'orders', 'refund'];
/**
* 构造方法。
*
* @param MerchantApiCredentialService $merchantApiCredentialService 商户 API 凭证服务
* @param PaymentTypeService $paymentTypeService 支付类型服务
* @param PayOrderService $payOrderService 支付订单服务
* @param PaymentPluginManager $paymentPluginManager 支付插件管理器
* @param MerchantAccountRepository $merchantAccountRepository 商户账户仓库
* @param BizOrderRepository $bizOrderRepository 业务订单仓库
* @param PayOrderRepository $payOrderRepository 支付订单仓库
* @param SettlementOrderRepository $settlementOrderRepository 结算订单仓库
* @param RefundService $refundService 退款服务
* @return void
*/
public function __construct(
protected MerchantApiCredentialService $merchantApiCredentialService,
protected MerchantService $merchantService,
protected PaymentTypeService $paymentTypeService,
protected PayOrderService $payOrderService,
protected PaymentOrderInputAssembler $orderInputAssembler,
protected PaymentPluginManager $paymentPluginManager,
protected EpaySignerManager $epaySignerManager,
protected MerchantAccountRepository $merchantAccountRepository,
protected BizOrderRepository $bizOrderRepository,
protected PayOrderRepository $payOrderRepository,
protected SettlementOrderRepository $settlementOrderRepository,
protected RefundService $refundService
) {
}
/**
* 处理页面跳转支付入口。
*
* @param array $payload 请求载荷
* @param Request $request 请求对象
* @return Response 跳转响应或错误 JSON
* @throws ValidationException
*/
public function submit(array $payload, Request $request): Response
{
try {
$typeCode = trim((string) ($payload['type'] ?? ''));
if ($typeCode === '') {
// `type` 为空时先创建收银台业务单,选完方式后再进入正式支付单流程。
$attempt = $this->prepareCashierSubmit($payload, $request);
$targetUrl = (string) ($attempt['cashier_url'] ?? '');
if ($targetUrl === '') {
throw new ValidationException('收银台跳转地址生成失败');
}
return redirect($targetUrl);
}
return $this->buildBrowserSubmitResponse($this->prepareSubmitAttempt($payload, $request));
} catch (Throwable $e) {
return json([
'code' => 0,
'msg' => $this->normalizeErrorMessage($e),
]);
}
}
/**
* 处理 API 支付入口。
*
* @param array $payload 请求载荷
* @param Request $request 请求对象
* @return array<string, mixed> ePay 风格响应
*/
public function mapi(array $payload, Request $request): array
{
try {
$attempt = $this->prepareSubmitAttempt($payload, $request);
return $this->buildMapiResponse($attempt);
} catch (Throwable $e) {
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)];
}
}
/**
* 处理旧版兼容入口。
*
* 支持 `query`、`settle`、`order`、`orders` 和 `refund` 五种操作。
*
* @param array $payload 请求载荷
* @return array<string, mixed> ePay 风格响应
*/
public function api(array $payload): array
{
$act = strtolower(trim((string) ($payload['act'] ?? '')));
if (!in_array($act, self::API_ACTIONS, true)) {
return ['code' => 0, 'msg' => '不支持的操作类型'];
}
return match ($act) {
'query' => $this->queryMerchantInfo($payload),
'settle' => $this->querySettlementList($payload),
'order' => $this->queryOrder($payload),
'orders' => $this->queryOrders($payload),
'refund' => $this->createRefund($payload),
};
}
/**
* 查询商户信息,对应 `act=query`。
*
* @param array $payload 请求载荷
* @return array<string, mixed> ePay 风格响应
*/
public function queryMerchantInfo(array $payload): array
{
try {
$merchantId = (int) ($payload['pid'] ?? 0);
$key = trim((string) ($payload['key'] ?? ''));
$auth = $this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
$merchant = $auth['merchant'];
$credential = $auth['credential'];
$account = $this->merchantAccountRepository->findByMerchantId($merchantId);
// 旧协议会同时返回总单量、今日单量和昨日单量,便于上游直接做商户概览。
$todayDate = FormatHelper::timestamp(time(), 'Y-m-d');
$lastDayDate = FormatHelper::timestamp(strtotime('-1 day'), 'Y-m-d');
$totalOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->count();
$todayOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->whereDate('created_at', $todayDate)->count();
$lastDayOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->whereDate('created_at', $lastDayDate)->count();
return [
'code' => 1,
'pid' => (int) $merchant->id,
'key' => (string) $credential->api_key,
'active' => (int) $merchant->status,
'money' => FormatHelper::amount((int) ($account->available_balance ?? 0)),
'type' => (int) ($merchant->settle_type ?? 4),
'account' => (string) $merchant->settlement_account_no,
'username' => (string) $merchant->settlement_account_name,
'orders' => $totalOrders,
'order_today' => $todayOrders,
'order_lastday' => $lastDayOrders,
];
} catch (Throwable $e) {
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)];
}
}
/**
* 查询结算记录列表,对应 `act=settle`。
*
* @param array $payload 请求载荷
* @return array<string, mixed> ePay 风格响应
*/
public function querySettlementList(array $payload): array
{
try {
$merchantId = (int) ($payload['pid'] ?? 0);
$key = trim((string) ($payload['key'] ?? ''));
$this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
$rows = $this->settlementOrderRepository->query()->where('merchant_id', $merchantId)->orderByDesc('id')->get();
// 旧协议列表只需要基础字段和金额文本,这里直接整理成可展示数组。
return [
'code' => 1,
'msg' => '查询结算记录成功!',
'data' => $rows->map(function ($row): array {
return [
'settle_no' => (string) $row->settle_no,
'cycle_type' => (int) $row->cycle_type,
'cycle_key' => (string) $row->cycle_key,
'status' => (int) $row->status,
'gross_amount' => FormatHelper::amount((int) $row->gross_amount),
'net_amount' => FormatHelper::amount((int) $row->net_amount),
'accounted_amount' => FormatHelper::amount((int) $row->accounted_amount),
'created_at' => FormatHelper::dateTime($row->created_at ?? null),
'completed_at' => FormatHelper::dateTime($row->completed_at ?? null),
];
})->all(),
];
} catch (Throwable $e) {
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)];
}
}
/**
* 查询单个订单,对应 `act=order`。
*
* @param array $payload 请求载荷
* @return array<string, mixed> ePay 风格响应
*/
public function queryOrder(array $payload): array
{
try {
$merchantId = (int) ($payload['pid'] ?? 0);
$key = trim((string) ($payload['key'] ?? ''));
$this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
$context = $this->resolvePayOrderContext($merchantId, $payload);
if (!$context) {
return ['code' => 0, 'msg' => '订单不存在'];
}
// 旧协议查询单号时,要把支付单和业务单合并成同一份响应结构。
return ['code' => 1, 'msg' => '查询订单号成功!'] + $this->formatEpayOrderRow($context['pay_order'], $context['biz_order']);
} catch (Throwable $e) {
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)];
}
}
/**
* 批量查询订单,对应 `act=orders`。
*
* @param array $payload 请求载荷
* @return array<string, mixed> ePay 风格响应
*/
public function queryOrders(array $payload): array
{
try {
$merchantId = (int) ($payload['pid'] ?? 0);
$key = trim((string) ($payload['key'] ?? ''));
$this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
// 旧接口默认只允许一次拉少量订单,这里沿用上限 50 的兼容口径。
$limit = min(50, max(1, (int) ($payload['limit'] ?? 20)));
$page = max(1, (int) ($payload['page'] ?? 1));
$paginator = $this->payOrderRepository->query()->where('merchant_id', $merchantId)->orderByDesc('id')->paginate($limit, ['*'], 'page', $page);
$items = $paginator->items();
$bizOrderMap = [];
$bizNos = array_values(array_unique(array_filter(array_map(function ($row): string {
return trim((string) ($row->biz_no ?? ''));
}, $items))));
if ($bizNos !== []) {
foreach ($this->bizOrderRepository->query()->whereIn('biz_no', $bizNos)->get() as $bizOrder) {
$bizOrderMap[(string) $bizOrder->biz_no] = $bizOrder;
}
}
return [
'code' => 1,
'msg' => '查询结算记录成功!',
// 批量查询和单条查询共用同一套格式化器,避免字段口径不一致。
'data' => array_map(function ($row) use ($bizOrderMap): array {
$bizNo = (string) ($row->biz_no ?? '');
return $this->formatEpayOrderRow($row, $bizOrderMap[$bizNo] ?? null);
}, $items),
];
} catch (Throwable $e) {
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)];
}
}
/**
* 提交退款申请,对应 `act=refund`。
*
* @param array $payload 请求载荷
* @return array<string, mixed> ePay 风格响应
*/
public function createRefund(array $payload): array
{
try {
$merchantId = (int) ($payload['pid'] ?? 0);
$key = trim((string) ($payload['key'] ?? ''));
$this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
// 先确认退款目标单据归属当前商户,避免旧协议拿着别人的单号误发退款。
$context = $this->resolvePayOrderContext($merchantId, $payload);
if (!$context) {
return ['code' => 0, 'msg' => '订单不存在'];
}
/** @var PayOrder $payOrder */
$payOrder = $context['pay_order'];
$refundAmount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0'));
if ($refundAmount <= 0) {
return ['code' => 0, 'msg' => '退款金额不合法'];
}
$refundOrder = $this->refundService->createRefund([
'pay_no' => (string) $payOrder->pay_no,
'merchant_refund_no' => trim((string) ($payload['refund_no'] ?? $payload['merchant_refund_no'] ?? '')),
'refund_amount' => $refundAmount,
'reason' => trim((string) ($payload['reason'] ?? '')),
]);
$plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true);
// 不同插件返回的退款结果字段不完全一致,这里仍按旧协议的退款参数重新组织一次。
$pluginResult = $plugin->refund([
'order_id' => (string) $payOrder->pay_no,
'pay_no' => (string) $payOrder->pay_no,
'biz_no' => (string) $payOrder->biz_no,
'chan_order_no' => (string) $payOrder->channel_order_no,
'chan_trade_no' => (string) $payOrder->channel_trade_no,
'out_trade_no' => (string) $payOrder->channel_order_no,
'refund_no' => (string) $refundOrder->refund_no,
'refund_amount' => $refundAmount,
'refund_reason' => trim((string) ($payload['reason'] ?? '')),
'extra' => (array) ($payOrder->ext_json ?? []),
]);
if (!$this->isPluginSuccess($pluginResult)) {
// 渠道明确失败时,先把退款单推进失败态,再把旧协议响应收口成失败文案。
$this->refundService->markRefundFailed((string) $refundOrder->refund_no, [
'failed_at' => $this->now(),
'last_error' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败'),
'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult),
]);
return ['code' => 0, 'msg' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败')];
}
$this->refundService->markRefundSuccess((string) $refundOrder->refund_no, [
'succeeded_at' => $this->now(),
'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult),
]);
return ['code' => 1, 'msg' => '退款成功'];
} catch (Throwable $e) {
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)];
}
}
/**
* 预处理支付提交请求。
*
* 这里负责把旧协议载荷转换为当前支付单创建所需的数据结构。
*
* @param array $payload 请求载荷
* @param Request $request 请求对象
* @return array<string, mixed> 预处理数据
*/
private function prepareSubmitAttempt(array $payload, Request $request): array
{
// 先把旧协议载荷转换成当前系统的统一入参,再交给支付单主流程处理。
$normalized = $this->normalizeSubmitPayload($payload, $request, false);
$result = $this->payOrderService->preparePayAttempt($normalized);
$payOrder = $result['pay_order'];
$payParams = (array) ($result['pay_params'] ?? []);
return [
'normalized_payload' => $normalized,
'result' => $result,
'pay_order' => $payOrder,
'pay_params' => $payParams,
'payment_page_url' => $this->buildPaymentPageUrl((string) $payOrder->pay_no),
];
}
/**
* 预创建收银台业务单。
*
* @param array $payload 请求载荷
* @param Request $request 请求对象
* @return array<string, mixed> 预处理数据
*/
private function prepareCashierSubmit(array $payload, Request $request): array
{
$normalized = $this->normalizeSubmitPayload($payload, $request, true);
$result = $this->payOrderService->prepareCashierBizOrder($normalized);
return [
'normalized_payload' => $normalized,
'result' => $result,
'merchant' => $result['merchant'] ?? null,
'biz_order' => $result['biz_order'] ?? null,
'cashier_url' => (string) ($result['cashier_url'] ?? ''),
];
}
/**
* 归一化提交支付参数。
*
* 这里会完成签名校验、金额转分、支付方式解析,并把旧协议字段写入扩展信息。
*
* @param array $payload 请求载荷
* @param Request $request 请求对象
* @return array<string, mixed> 当前支付单创建参数
* @throws ValidationException
*/
private function normalizeSubmitPayload(array $payload, Request $request, bool $allowEmptyType = false): array
{
$merchantId = (int) ($payload['pid'] ?? 0);
if ($merchantId <= 0) {
throw new ValidationException('pid 参数不能为空');
}
$sign = trim((string) ($payload['sign'] ?? ''));
if ($sign === '') {
throw new ValidationException('sign 参数不能为空');
}
$credential = $this->merchantApiCredentialService->findByMerchantId($merchantId);
if (!$credential || (int) $credential->status !== AuthConstant::CREDENTIAL_STATUS_ENABLED) {
throw new ValidationException('商户 API 凭证未开通');
}
$signType = strtoupper((string) ($payload['sign_type'] ?? AuthConstant::API_SIGN_NAME_MD5));
if ($signType !== AuthConstant::API_SIGN_NAME_MD5) {
throw new ValidationException('仅支持 MD5 签名');
}
if (!$this->epaySignerManager->verify(
$this->buildV1SignParams($payload),
$signType,
$sign,
(string) $credential->api_key
)) {
throw new ValidationException('签名验证失败');
}
$this->merchantService->ensureMerchantEnabled($merchantId);
$typeCode = trim((string) ($payload['type'] ?? ''));
$merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? ''));
$subject = trim((string) ($payload['name'] ?? ''));
$amount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0'));
$paymentType = null;
if ($typeCode === '') {
if (!$allowEmptyType) {
throw new ValidationException('type 参数不能为空');
}
} else {
$paymentType = $this->resolveSubmitPaymentType($typeCode);
}
if ($merchantOrderNo === '') {
throw new ValidationException('out_trade_no 参数不能为空');
}
if ($subject === '') {
throw new ValidationException('name 参数不能为空');
}
if ($amount <= 0) {
throw new ValidationException('money 参数不合法');
}
// 旧协议的展示字段统一交给 assembler避免 submit / mapi / cashier 三处口径漂移。
$orderFields = $this->orderInputAssembler->buildOrderFields($payload, $request, null, [
'_protocol_version' => 'v1',
]);
$normalized = [
'merchant_id' => (int) ($payload['pid'] ?? 0),
'merchant_order_no' => $merchantOrderNo,
'pay_amount' => $amount,
'subject' => (string) $orderFields['subject'],
'body' => (string) $orderFields['body'],
'notify_url' => (string) $orderFields['notify_url'],
'return_url' => (string) $orderFields['return_url'],
'client_ip' => (string) $orderFields['client_ip'],
'device' => (string) $orderFields['device'],
'ext_json' => (array) $orderFields['ext_json'],
];
if ($paymentType) {
$normalized['pay_type_id'] = (int) $paymentType->id;
}
return $normalized;
}
/**
* 过滤旧协议签名参数。
*
* @param array $payload 请求载荷
* @return array<string, mixed> 签名参数
*/
private function buildV1SignParams(array $payload): array
{
$params = $payload;
unset($params['sign'], $params['sign_type'], $params['key']);
foreach ($params as $paramKey => $paramValue) {
if ($paramValue === '' || $paramValue === null) {
unset($params[$paramKey]);
}
}
return $params;
}
/**
* 解析提交支付方式。
*
* 只接受显式传入且启用中的支付方式。
*
* @param string $typeCode 支付方式编码
* @return PaymentType 支付方式模型
* @throws ValidationException
*/
private function resolveSubmitPaymentType(string $typeCode): PaymentType
{
$typeCode = trim($typeCode);
$paymentType = $this->paymentTypeService->findByCode($typeCode);
if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) {
throw new ValidationException('支付方式不支持');
}
return $paymentType;
}
/**
* 构建旧版 MAPI 返回结构。
*
* 根据当前支付尝试结果,输出 payurl、qrcode 或 urlscheme 等旧协议字段。
*
* @param array $attempt 支付尝试结果
* @return array<string, mixed> ePay 风格响应
*/
private function buildMapiResponse(array $attempt): array
{
/** @var PayOrder $payOrder */
$payOrder = $attempt['pay_order'];
$payParams = (array) ($attempt['pay_params'] ?? []);
$normalizedPayload = (array) ($attempt['normalized_payload'] ?? []);
$paymentPageUrl = (string) ($attempt['payment_page_url'] ?? $this->buildPaymentPageUrl((string) $payOrder->pay_no));
$payNo = (string) $payOrder->pay_no;
$response = ['code' => 1, 'msg' => '提交成功', 'trade_no' => $payNo];
$device = strtolower(trim((string) ($normalizedPayload['device'] ?? '')));
$type = strtolower(trim((string) ($payParams['type'] ?? '')));
$resolved = $this->resolveV1PayResponse($payParams, $device, $paymentPageUrl, $type);
if ($resolved['field'] !== '') {
$response[$resolved['field']] = $resolved['value'];
}
return $response;
}
/**
* 解析旧版 MAPI 的单字段返回体。
*
* 旧协议同一时刻只会返回 `payurl`、`qrcode`、`urlscheme` 中的一个。
*
* @param array<string, mixed> $payParams 插件返回参数
* @param string $device 请求设备
* @param string $paymentPageUrl 支付页地址
* @param string $type 插件返回类型
* @return array{field: string, value: string}
*/
private function resolveV1PayResponse(array $payParams, string $device, string $paymentPageUrl, string $type): array
{
if ($device === 'jump') {
return ['field' => 'payurl', 'value' => $paymentPageUrl];
}
if ($type === 'qrcode') {
$qrcode = $this->stringifyValue($payParams['qrcode_url'] ?? '');
if ($qrcode !== '') {
return ['field' => 'qrcode', 'value' => $qrcode];
}
}
if ($type === 'jsapi') {
$urlscheme = $this->stringifyValue($payParams['order_string'] ?? '');
if ($urlscheme !== '') {
return ['field' => 'urlscheme', 'value' => $urlscheme];
}
}
return ['field' => 'payurl', 'value' => $paymentPageUrl];
}
/**
* 将当前支付单格式化为旧版订单查询结构。
*
* @param PayOrder $payOrder 支付订单
* @param BizOrder|null $bizOrder 业务订单
* @return array<string, mixed> 旧版订单结构
*/
private function formatEpayOrderRow(PayOrder $payOrder, ?BizOrder $bizOrder = null): array
{
$bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
$extJson = (array) (($bizOrder?->ext_json) ?? []);
$merchantExt = (array) ($extJson['merchant'] ?? []);
return [
'trade_no' => (string) $payOrder->pay_no,
'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? $extJson['merchant_order_no'] ?? ''),
'api_trade_no' => (string) ($payOrder->channel_trade_no ?: $payOrder->channel_order_no ?: ''),
'type' => $this->resolvePaymentTypeCode((int) $payOrder->pay_type_id),
'pid' => (int) $payOrder->merchant_id,
'addtime' => FormatHelper::dateTime($payOrder->created_at),
'endtime' => FormatHelper::dateTime($payOrder->paid_at),
'name' => (string) ($bizOrder?->subject ?? $extJson['subject'] ?? ''),
'money' => FormatHelper::amount((int) $payOrder->pay_amount),
'status' => (int) $payOrder->status === TradeConstant::ORDER_STATUS_SUCCESS ? 1 : 0,
'param' => $this->stringifyValue($merchantExt['param'] ?? ''),
'buyer' => $this->stringifyValue($merchantExt['buyer'] ?? ''),
];
}
/**
* 解析支付订单上下文。
*
* 优先按 `trade_no` 查找,其次按 `out_trade_no` 回退,并校验订单归属当前商户。
*
* @param int $merchantId 商户ID
* @param array $payload 请求载荷
* @return array{pay_order: PayOrder, biz_order: BizOrder|null}|null 上下文
*/
private function resolvePayOrderContext(int $merchantId, array $payload): ?array
{
$payNo = trim((string) ($payload['trade_no'] ?? ''));
$merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? ''));
$payOrder = null;
$bizOrder = null;
if ($payNo !== '') {
// 旧协议如果传了 trade_no就优先按支付单号定位命中率最高。
$payOrder = $this->payOrderRepository->findByPayNo($payNo);
if ($payOrder) {
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
}
}
if (!$payOrder && $merchantOrderNo !== '') {
// 没有 trade_no 时,再按商户单号反查业务单和最新支付单。
$bizOrder = $this->bizOrderRepository->findByMerchantAndOrderNo($merchantId, $merchantOrderNo);
if ($bizOrder) {
// 旧协议经常只传商户单号,这里拿业务单找到最新一笔支付单。
$payOrder = $this->payOrderRepository->findLatestByBizNo((string) $bizOrder->biz_no);
}
}
// 旧协议有时会传到别家商户的单号,这里必须再次校验归属,避免跨商户读取。
if (!$payOrder || (int) $payOrder->merchant_id !== $merchantId) {
return null;
}
if (!$bizOrder) {
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
}
return ['pay_order' => $payOrder, 'biz_order' => $bizOrder];
}
/**
* 根据支付方式 ID 解析支付方式编码。
*
* @param int $payTypeId 支付方式ID
* @return string 支付方式编码
*/
private function resolvePaymentTypeCode(int $payTypeId): string
{
return $this->paymentTypeService->resolveCodeById($payTypeId);
}
/**
* 将元金额转成分。
*
* @param string $money 金额字符串
* @return int 金额分值,非法时返回 0
*/
private function parseMoneyToAmount(string $money): int
{
$money = trim($money);
if ($money === '' || !preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) {
return 0;
}
[$integer, $fraction] = array_pad(explode('.', $money, 2), 2, '');
$fraction = str_pad($fraction, 2, '0');
// 旧协议金额按“元”传入,内部统一转成“分”处理,避免 float 精度漂移。
return ((int) $integer) * 100 + (int) substr($fraction, 0, 2);
}
/**
* 规范化异常提示。
*
* @param Throwable $e 异常对象
* @return string 错误提示
*/
private function normalizeErrorMessage(Throwable $e): string
{
return $e->getMessage() !== '' ? $e->getMessage() : '请求失败';
}
/**
* 构建支付页地址。
*
* @param string $payNo 支付单号
* @return string 支付页 URL
*/
private function buildPaymentPageUrl(string $payNo): string
{
return rtrim((string) sys_config('site_url'), '/') . '/payment/' . rawurlencode($payNo);
}
/**
* 按支付载体生成浏览器响应。
*
* 页面跳转支付允许直接重定向或直接输出 HTML其余载体统一回到平台支付页。
*
* @param array<string, mixed> $attempt 支付尝试结果
* @return Response
*/
private function buildBrowserSubmitResponse(array $attempt): Response
{
$payParams = (array) ($attempt['pay_params'] ?? []);
$payType = strtolower(trim((string) ($payParams['type'] ?? '')));
$paymentPageUrl = (string) ($attempt['payment_page_url'] ?? '');
if (in_array($payType, ['jump', 'url', 'web', 'h5'], true)) {
$jumpUrl = $this->resolveBrowserPayUrl($payParams);
if ($jumpUrl !== '') {
return redirect($jumpUrl);
}
}
if (in_array($payType, ['html', 'form'], true)) {
$html = $this->resolveBrowserHtml($payParams);
if ($html !== '') {
return response($html, 200, [
'Content-Type' => 'text/html; charset=utf-8',
]);
}
}
if ($paymentPageUrl === '') {
throw new ValidationException('支付页跳转地址生成失败');
}
return redirect($paymentPageUrl);
}
/**
* 提取浏览器跳转地址。
*
* @param array<string, mixed> $payParams 支付参数
* @return string
*/
private function resolveBrowserPayUrl(array $payParams): string
{
foreach (['payurl', 'pay_url', 'url', 'redirect_url', 'mweb_url'] as $key) {
$value = $this->stringifyValue($payParams[$key] ?? '');
if ($value !== '') {
return $value;
}
}
return '';
}
/**
* 提取浏览器可直接渲染的 HTML。
*
* @param array<string, mixed> $payParams 支付参数
* @return string
*/
private function resolveBrowserHtml(array $payParams): string
{
foreach (['html', 'html_form', 'form_html'] as $key) {
$value = $payParams[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return '';
}
/**
* 判断插件返回的 success 标记。
*
* 如果插件未显式返回 `success`,则默认视为成功。
*
* @param array $pluginResult 插件结果
* @return bool 插件是否通过
*/
private function isPluginSuccess(array $pluginResult): bool
{
return !array_key_exists('success', $pluginResult) || (bool) $pluginResult['success'];
}
/**
* 解析退款渠道单号。
*
* @param array $pluginResult 插件结果
* @param string $default 默认值
* @return string 渠道退款单号
*/
private function resolveRefundChannelNo(array $pluginResult, string $default = ''): string
{
foreach (['chan_refund_no', 'refund_no', 'trade_no', 'out_request_no'] as $key) {
if (array_key_exists($key, $pluginResult)) {
$value = $this->stringifyValue($pluginResult[$key]);
if ($value !== '') {
return $value;
}
}
}
return $default;
}
/**
* 将任意值规范化为字符串。
*
* @param array|object|bool|float|int|string|null $value 待转换值
* @return string 规范化后的字符串
*/
private function stringifyValue(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_string($value)) {
return trim($value);
}
if (is_int($value) || is_float($value) || is_bool($value)) {
return (string) $value;
}
if (is_array($value) || is_object($value)) {
// 复杂结构直接 JSON 化,保证旧协议回显时仍然可读。
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $json !== false ? $json : '';
}
return (string) $value;
}
}

View File

@@ -0,0 +1,996 @@
<?php
namespace app\service\payment\epay;
use app\common\base\BaseService;
use app\common\constant\AuthConstant;
use app\common\constant\CommonConstant;
use app\common\constant\RouteConstant;
use app\common\constant\TradeConstant;
use app\common\util\FormatHelper;
use app\exception\ConflictException;
use app\exception\ResourceNotFoundException;
use app\exception\ValidationException;
use app\model\merchant\Merchant;
use app\model\payment\BizOrder;
use app\model\payment\PayOrder;
use app\model\payment\RefundOrder;
use app\repository\account\balance\MerchantAccountRepository;
use app\repository\merchant\credential\MerchantApiCredentialRepository;
use app\repository\payment\trade\BizOrderRepository;
use app\repository\payment\trade\PayOrderRepository;
use app\repository\payment\trade\RefundOrderRepository;
use app\service\merchant\MerchantService;
use app\service\payment\order\PayOrderQueryService;
use app\service\payment\order\PayOrderService;
use app\service\payment\order\PaymentOrderInputAssembler;
use app\service\payment\order\RefundQueryService;
use app\service\payment\order\RefundService;
use app\service\payment\transfer\TransferService;
use app\service\payment\runtime\PaymentPluginManager;
use app\service\payment\config\PaymentTypeService;
use support\Request;
use support\Response;
use Throwable;
/**
* ePay V2 协议服务。
*/
class EpayV2ProtocolService extends BaseService
{
public function __construct(
protected MerchantService $merchantService,
protected MerchantApiCredentialRepository $merchantApiCredentialRepository,
protected PaymentTypeService $paymentTypeService,
protected PayOrderService $payOrderService,
protected PayOrderQueryService $payOrderQueryService,
protected RefundService $refundService,
protected RefundQueryService $refundQueryService,
protected PayOrderRepository $payOrderRepository,
protected BizOrderRepository $bizOrderRepository,
protected RefundOrderRepository $refundOrderRepository,
protected MerchantAccountRepository $merchantAccountRepository,
protected PaymentPluginManager $paymentPluginManager,
protected TransferService $transferService,
protected EpaySignerManager $signerManager,
protected PaymentOrderInputAssembler $orderInputAssembler
) {
}
public function submit(array $payload, Request $request): Response
{
try {
$typeCode = trim((string) ($payload['type'] ?? ''));
if ($typeCode === '') {
// `type` 为空时先回收银台,显式选完方式后再创建支付单。
$attempt = $this->prepareCashierSubmit($payload, $request);
$cashierUrl = (string) ($attempt['cashier_url'] ?? '');
if ($cashierUrl === '') {
throw new ValidationException('收银台跳转地址生成失败');
}
return redirect($cashierUrl);
}
return $this->buildBrowserSubmitResponse($this->preparePayAttempt($payload, $request, false));
} catch (Throwable $e) {
return json($this->signResponse([
'code' => $this->resolveFailureCode($e),
'msg' => $this->normalizeErrorMessage($e),
]));
}
}
public function create(array $payload, Request $request): array
{
try {
$attempt = $this->preparePayAttempt($payload, $request, true);
return $this->signResponse($this->buildCreateResponse($attempt));
} catch (Throwable $e) {
return $this->signResponse([
'code' => $this->resolveFailureCode($e),
'msg' => $this->normalizeErrorMessage($e),
]);
}
}
public function query(array $payload): array
{
try {
$merchant = $this->authorizeMerchant($payload, true);
$context = $this->resolvePayOrderContext((int) $merchant->id, $payload);
if (!$context) {
throw new ResourceNotFoundException('订单不存在');
}
return $this->signResponse($this->buildOrderResponse($context['pay_order'], $context['biz_order']));
} catch (Throwable $e) {
return $this->signResponse([
'code' => $this->resolveFailureCode($e),
'msg' => $this->normalizeErrorMessage($e),
]);
}
}
public function refund(array $payload): array
{
try {
$merchant = $this->authorizeMerchant($payload, true);
$context = $this->resolvePayOrderContext((int) $merchant->id, $payload);
if (!$context) {
throw new ResourceNotFoundException('订单不存在');
}
/** @var PayOrder $payOrder */
$payOrder = $context['pay_order'];
$refundAmount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0'));
if ($refundAmount <= 0) {
throw new ValidationException('money 参数不合法');
}
$merchantRefundNo = trim((string) ($payload['out_refund_no'] ?? $payload['refund_no'] ?? ''));
$refundOrder = $this->refundService->createRefund([
'pay_no' => (string) $payOrder->pay_no,
'merchant_refund_no' => $merchantRefundNo,
'refund_amount' => $refundAmount,
'reason' => trim((string) ($payload['reason'] ?? '')),
]);
$plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true);
$pluginResult = $plugin->refund([
'order_id' => (string) $payOrder->pay_no,
'pay_no' => (string) $payOrder->pay_no,
'biz_no' => (string) $payOrder->biz_no,
'chan_order_no' => (string) $payOrder->channel_order_no,
'chan_trade_no' => (string) $payOrder->channel_trade_no,
'out_trade_no' => (string) $payOrder->channel_order_no,
'refund_no' => (string) $refundOrder->refund_no,
'refund_amount' => $refundAmount,
'refund_reason' => trim((string) ($payload['reason'] ?? '')),
'extra' => (array) ($payOrder->ext_json ?? []),
]);
if (!$this->isPluginSuccess($pluginResult)) {
$this->refundService->markRefundFailed((string) $refundOrder->refund_no, [
'failed_at' => $this->now(),
'last_error' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败'),
'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult),
]);
throw new ValidationException((string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败'));
}
$this->refundService->markRefundSuccess((string) $refundOrder->refund_no, [
'succeeded_at' => $this->now(),
'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult),
]);
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
return $this->signResponse($this->buildRefundResponse($refundOrder->refresh(), $payOrder->refresh(), $bizOrder));
} catch (Throwable $e) {
return $this->signResponse([
'code' => $this->resolveFailureCode($e),
'msg' => $this->normalizeErrorMessage($e),
]);
}
}
public function refundQuery(array $payload): array
{
try {
$merchant = $this->authorizeMerchant($payload, true);
$refundOrder = $this->resolveRefundOrder((int) $merchant->id, $payload);
$payOrder = $this->payOrderRepository->findByPayNo((string) $refundOrder->pay_no);
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $refundOrder->biz_no);
return $this->signResponse($this->buildRefundResponse($refundOrder, $payOrder, $bizOrder));
} catch (Throwable $e) {
return $this->signResponse([
'code' => $this->resolveFailureCode($e),
'msg' => $this->normalizeErrorMessage($e),
]);
}
}
public function close(array $payload): array
{
try {
$merchant = $this->authorizeMerchant($payload, true);
$context = $this->resolvePayOrderContext((int) $merchant->id, $payload);
if (!$context) {
throw new ResourceNotFoundException('订单不存在');
}
/** @var PayOrder $payOrder */
$payOrder = $context['pay_order'];
$currentStatus = (int) $payOrder->status;
if ($currentStatus === TradeConstant::ORDER_STATUS_CLOSED) {
return $this->signResponse([
'code' => $this->successCode(),
'msg' => 'success',
]);
}
if ($currentStatus === TradeConstant::ORDER_STATUS_SUCCESS) {
throw new ValidationException('订单已支付成功,不能关闭');
}
if (TradeConstant::isOrderTerminalStatus($currentStatus)) {
throw new ValidationException('订单已结束,不能关闭');
}
$plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true);
$pluginResult = $plugin->close([
'order_id' => (string) $payOrder->pay_no,
'pay_no' => (string) $payOrder->pay_no,
'biz_no' => (string) $payOrder->biz_no,
'chan_order_no' => (string) $payOrder->channel_order_no,
'chan_trade_no' => (string) $payOrder->channel_trade_no,
'out_trade_no' => (string) ($payOrder->channel_order_no ?: $payOrder->pay_no),
'extra' => (array) ($payOrder->ext_json ?? []),
]);
if (!$this->isPluginSuccess($pluginResult)) {
throw new ValidationException((string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '渠道关单失败'));
}
$closeReason = (string) ($pluginResult['msg'] ?? 'ePay V2 手动关闭');
$this->payOrderService->closePayOrder((string) $payOrder->pay_no, [
'closed_at' => $this->now(),
'reason' => $closeReason,
'ext_json' => [
'plugin' => [
'close_result' => $pluginResult,
],
],
]);
return $this->signResponse([
'code' => $this->successCode(),
'msg' => 'success',
]);
} catch (Throwable $e) {
return $this->signResponse([
'code' => $this->resolveFailureCode($e),
'msg' => $this->normalizeErrorMessage($e),
]);
}
}
public function merchantInfo(array $payload): array
{
try {
$merchant = $this->authorizeMerchant($payload, true);
$account = $this->merchantAccountRepository->findByMerchantId((int) $merchant->id);
$today = $this->nowDate();
$yesterday = $this->yesterdayDate();
$orderQuery = $this->payOrderRepository->query()->where('merchant_id', (int) $merchant->id);
$totalOrders = (int) (clone $orderQuery)->count();
$todayOrders = (int) (clone $orderQuery)->whereDate('created_at', $today)->count();
$yesterdayOrders = (int) (clone $orderQuery)->whereDate('created_at', $yesterday)->count();
$todayMoney = (int) (clone $orderQuery)->whereDate('created_at', $today)->sum('pay_amount');
$yesterdayMoney = (int) (clone $orderQuery)->whereDate('created_at', $yesterday)->sum('pay_amount');
return $this->signResponse([
'code' => $this->successCode(),
'msg' => 'success',
'pid' => (int) $merchant->id,
'status' => (int) $merchant->status,
'pay_status' => (int) ($merchant->pay_status ?? 1),
'settle_status' => (int) ($merchant->settle_status ?? 1),
'money' => $this->formatAmount((int) ($account->available_balance ?? 0)),
'settle_type' => (int) ($merchant->settle_type ?? 4),
'settle_account' => (string) ($merchant->settlement_account_no ?? ''),
'settle_name' => (string) ($merchant->settlement_account_name ?? ''),
'order_num' => $totalOrders,
'order_num_today' => $todayOrders,
'order_num_lastday' => $yesterdayOrders,
'order_money_today' => $this->formatAmount($todayMoney),
'order_money_lastday' => $this->formatAmount($yesterdayMoney),
]);
} catch (Throwable $e) {
return $this->signResponse([
'code' => $this->resolveFailureCode($e),
'msg' => $this->normalizeErrorMessage($e),
]);
}
}
public function merchantOrders(array $payload): array
{
try {
$merchant = $this->authorizeMerchant($payload, true);
$limit = min(50, max(1, (int) ($payload['limit'] ?? 20)));
$offset = max(0, (int) ($payload['offset'] ?? 0));
$page = (int) floor($offset / $limit) + 1;
$filters = [];
if (array_key_exists('status', $payload) && $payload['status'] !== '') {
$filters['status'] = (int) $payload['status'];
}
$result = $this->payOrderQueryService->paginate($filters, $page, $limit, (int) $merchant->id);
return $this->signResponse([
'code' => $this->successCode(),
'msg' => 'success',
'data' => $result['list'] ?? [],
]);
} catch (Throwable $e) {
return $this->signResponse([
'code' => $this->resolveFailureCode($e),
'msg' => $this->normalizeErrorMessage($e),
]);
}
}
public function transferSubmit(array $payload): array
{
try {
$merchant = $this->authorizeMerchant($payload, true);
$data = $this->transferService->submit($merchant, $payload);
return $this->signResponse([
'code' => $this->successCode(),
'msg' => 'success',
'status' => (int) ($data['status'] ?? 0),
'biz_no' => (string) ($data['biz_no'] ?? ''),
'out_biz_no' => (string) ($data['out_biz_no'] ?? ''),
'orderid' => (string) ($data['orderid'] ?? ''),
'paydate' => (string) ($data['paydate'] ?? ''),
'cost_money' => (string) ($data['cost_money'] ?? ''),
]);
} catch (Throwable $e) {
return $this->signResponse([
'code' => $this->resolveFailureCode($e),
'msg' => $this->normalizeErrorMessage($e),
]);
}
}
public function transferQuery(array $payload): array
{
try {
$merchant = $this->authorizeMerchant($payload, true);
$data = $this->transferService->query($merchant, $payload);
return $this->signResponse([
'code' => $this->successCode(),
'msg' => 'success',
] + $data);
} catch (Throwable $e) {
return $this->signResponse([
'code' => $this->resolveFailureCode($e),
'msg' => $this->normalizeErrorMessage($e),
]);
}
}
public function transferBalance(array $payload): array
{
try {
$merchant = $this->authorizeMerchant($payload, true);
$data = $this->transferService->balance($merchant);
return $this->signResponse([
'code' => $this->successCode(),
'msg' => 'success',
] + $data);
} catch (Throwable $e) {
return $this->signResponse([
'code' => $this->resolveFailureCode($e),
'msg' => $this->normalizeErrorMessage($e),
]);
}
}
/**
* 预创建支付。
*
* @param array $payload 请求参数
* @param Request $request 请求对象
* @param bool $requireType 是否强制要求 type
* @return array<string, mixed>
*/
private function preparePayAttempt(array $payload, Request $request, bool $requireType): array
{
$merchant = $this->authorizeMerchant($payload, true);
$typeCode = trim((string) ($payload['type'] ?? ''));
if ($requireType && $typeCode === '') {
throw new ValidationException('type 不能为空');
}
$paymentType = $this->resolvePaymentType($typeCode);
$merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? ''));
$subject = trim((string) ($payload['name'] ?? ''));
$amount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0'));
if ($merchantOrderNo === '') {
throw new ValidationException('out_trade_no 不能为空');
}
if ($subject === '') {
throw new ValidationException('name 不能为空');
}
if ($amount <= 0) {
throw new ValidationException('money 参数不合法');
}
// V2 直连支付和收银台确认共用同一套字段归一化逻辑。
$orderFields = $this->orderInputAssembler->buildOrderFields($payload, $request, null, [
'_protocol_version' => 'v2',
]);
$normalized = [
'merchant_id' => (int) $merchant->id,
'merchant_order_no' => $merchantOrderNo,
'pay_type_id' => (int) $paymentType->id,
'pay_amount' => $amount,
'subject' => (string) $orderFields['subject'],
'body' => (string) $orderFields['body'],
'notify_url' => (string) $orderFields['notify_url'],
'return_url' => (string) $orderFields['return_url'],
'client_ip' => (string) $orderFields['client_ip'],
'device' => (string) $orderFields['device'],
'channel_id' => (int) ($payload['channel_id'] ?? 0),
'ext_json' => (array) $orderFields['ext_json'],
];
$attempt = $this->payOrderService->preparePayAttempt($normalized);
$payOrder = $attempt['pay_order'];
return [
'merchant' => $merchant,
'pay_order' => $payOrder,
'payment_result' => $attempt['payment_result'] ?? [],
'pay_params' => $attempt['pay_params'] ?? [],
'payment_page_url' => $this->buildPaymentPageUrl((string) $payOrder->pay_no),
];
}
/**
* 预创建收银台业务单。
*
* @param array $payload 请求参数
* @param Request $request 请求对象
* @return array<string, mixed>
*/
private function prepareCashierSubmit(array $payload, Request $request): array
{
$merchant = $this->authorizeMerchant($payload, true);
$merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? ''));
$subject = trim((string) ($payload['name'] ?? ''));
$amount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0'));
if ($merchantOrderNo === '') {
throw new ValidationException('out_trade_no 不能为空');
}
if ($subject === '') {
throw new ValidationException('name 不能为空');
}
if ($amount <= 0) {
throw new ValidationException('money 参数不合法');
}
// 收银台首屏只需要业务单上下文,不在这里创建支付单。
$orderFields = $this->orderInputAssembler->buildOrderFields($payload, $request, null, [
'_protocol_version' => 'v2',
]);
$normalized = [
'merchant_id' => (int) $merchant->id,
'merchant_order_no' => $merchantOrderNo,
'pay_amount' => $amount,
'subject' => (string) $orderFields['subject'],
'body' => (string) $orderFields['body'],
'notify_url' => (string) $orderFields['notify_url'],
'return_url' => (string) $orderFields['return_url'],
'client_ip' => (string) $orderFields['client_ip'],
'device' => (string) $orderFields['device'],
'ext_json' => (array) $orderFields['ext_json'],
];
$result = $this->payOrderService->prepareCashierBizOrder($normalized);
return [
'merchant' => $merchant,
'biz_order' => $result['biz_order'] ?? null,
'cashier_url' => (string) ($result['cashier_url'] ?? ''),
];
}
/**
* 构建创建支付响应。
*
* @param array<string, mixed> $attempt 支付尝试结果
* @return array<string, mixed>
*/
private function buildCreateResponse(array $attempt): array
{
/** @var PayOrder $payOrder */
$payOrder = $attempt['pay_order'];
$payParams = (array) ($attempt['pay_params'] ?? []);
$paymentResult = (array) ($attempt['payment_result'] ?? []);
return [
'code' => $this->successCode(),
'msg' => 'success',
'trade_no' => (string) $payOrder->pay_no,
'pay_type' => strtolower(trim((string) ($payParams['type'] ?? $paymentResult['pay_type'] ?? 'qrcode'))),
'pay_info' => $payParams,
];
}
/**
* 解析支付上下文。
*
* @param int $merchantId 商户ID
* @param array $payload 请求参数
* @return array{pay_order: PayOrder, biz_order: BizOrder|null}|null
*/
private function resolvePayOrderContext(int $merchantId, array $payload): ?array
{
$payNo = trim((string) ($payload['trade_no'] ?? ''));
$merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? ''));
$payOrder = null;
$bizOrder = null;
if ($payNo !== '') {
$payOrder = $this->payOrderRepository->findByPayNo($payNo);
if ($payOrder) {
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
}
}
if (!$payOrder && $merchantOrderNo !== '') {
$bizOrder = $this->bizOrderRepository->findByMerchantAndOrderNo($merchantId, $merchantOrderNo);
if ($bizOrder) {
$payOrder = $this->payOrderRepository->findLatestByBizNo((string) $bizOrder->biz_no);
}
}
if (!$payOrder || (int) $payOrder->merchant_id !== $merchantId) {
return null;
}
if (!$bizOrder) {
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
}
return [
'pay_order' => $payOrder,
'biz_order' => $bizOrder,
];
}
/**
* 解析退款单。
*
* @param int $merchantId 商户ID
* @param array $payload 请求参数
* @return RefundOrder
*/
private function resolveRefundOrder(int $merchantId, array $payload): RefundOrder
{
$refundNo = trim((string) ($payload['refund_no'] ?? ''));
$outRefundNo = trim((string) ($payload['out_refund_no'] ?? ''));
if ($refundNo !== '') {
$refundOrder = $this->refundOrderRepository->findByRefundNo($refundNo);
if (!$refundOrder || (int) $refundOrder->merchant_id !== $merchantId) {
throw new ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]);
}
return $refundOrder;
}
if ($outRefundNo !== '') {
$refundOrder = $this->refundOrderRepository->findByMerchantRefundNo($merchantId, $outRefundNo);
if (!$refundOrder) {
throw new ResourceNotFoundException('退款单不存在', ['out_refund_no' => $outRefundNo]);
}
return $refundOrder;
}
throw new ValidationException('refund_no/out_refund_no 不能为空');
}
/**
* 构建订单响应。
*
* @param PayOrder $payOrder 支付单
* @param BizOrder|null $bizOrder 业务单
* @return array<string, mixed>
*/
private function buildOrderResponse(PayOrder $payOrder, ?BizOrder $bizOrder = null): array
{
$bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
$bizExtJson = (array) (($bizOrder?->ext_json) ?? []);
$merchantExt = (array) ($bizExtJson['merchant'] ?? []);
$refundAmount = (int) ($bizOrder?->refund_amount ?? 0);
return [
'code' => $this->successCode(),
'msg' => 'success',
'trade_no' => (string) $payOrder->pay_no,
'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? ''),
'api_trade_no' => (string) ($payOrder->channel_trade_no ?: $payOrder->channel_order_no ?: ''),
'type' => $this->resolvePaymentTypeCode((int) $payOrder->pay_type_id),
'status' => $this->resolveEpayOrderStatus($payOrder, $refundAmount),
'pid' => (int) $payOrder->merchant_id,
'addtime' => FormatHelper::dateTime($payOrder->created_at),
'endtime' => FormatHelper::dateTime($payOrder->paid_at),
'name' => (string) ($bizOrder?->subject ?? ''),
'money' => FormatHelper::amount((int) $payOrder->pay_amount),
'refundmoney' => FormatHelper::amount($refundAmount),
'param' => $this->stringifyValue($merchantExt['param'] ?? ''),
'buyer' => $this->stringifyValue($merchantExt['buyer'] ?? ''),
'clientip' => $this->stringifyValue($payOrder->client_ip ?? ''),
];
}
/**
* 构建退款响应。
*
* @param RefundOrder $refundOrder 退款单
* @param PayOrder|null $payOrder 支付单
* @param BizOrder|null $bizOrder 业务单
* @return array<string, mixed>
*/
private function buildRefundResponse(RefundOrder $refundOrder, ?PayOrder $payOrder = null, ?BizOrder $bizOrder = null): array
{
$payOrder ??= $this->payOrderRepository->findByPayNo((string) $refundOrder->pay_no);
$bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $refundOrder->biz_no);
return [
'code' => $this->successCode(),
'msg' => 'success',
'refund_no' => (string) $refundOrder->refund_no,
'out_refund_no' => (string) $refundOrder->merchant_refund_no,
'trade_no' => (string) $refundOrder->pay_no,
'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? ''),
'money' => FormatHelper::amount((int) $refundOrder->refund_amount),
'reducemoney' => FormatHelper::amount((int) ($bizOrder?->refund_amount ?? 0)),
'status' => (int) $refundOrder->status === TradeConstant::REFUND_STATUS_SUCCESS ? 1 : 0,
'addtime' => FormatHelper::dateTime($refundOrder->created_at),
];
}
/**
* 解析支付方式。
*
* @param string $typeCode 支付方式编码
* @return \app\model\payment\PaymentType
*/
private function resolvePaymentType(string $typeCode)
{
$typeCode = trim($typeCode);
$paymentType = $this->paymentTypeService->findByCode($typeCode);
if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) {
throw new ValidationException('支付方式不支持');
}
return $paymentType;
}
/**
* 根据支付方式 ID 解析支付方式编码。
*
* @param int $payTypeId 支付方式ID
* @return string
*/
private function resolvePaymentTypeCode(int $payTypeId): string
{
return $this->paymentTypeService->resolveCodeById($payTypeId);
}
/**
* 计算 ePay 查询状态。
*
* @param PayOrder $payOrder 支付单
* @param int $refundAmount 已退款金额
* @return int
*/
private function resolveEpayOrderStatus(PayOrder $payOrder, int $refundAmount): int
{
if ((int) $payOrder->status === TradeConstant::ORDER_STATUS_SUCCESS) {
return $refundAmount > 0 ? 2 : 1;
}
return 0;
}
/**
* 认证商户并校验请求签名。
*
* @param array $payload 请求参数
* @param bool $verifySignature 是否验签
* @return Merchant
*/
private function authorizeMerchant(array $payload, bool $verifySignature): Merchant
{
$merchantId = (int) ($payload['pid'] ?? 0);
if ($merchantId <= 0) {
throw new ValidationException('pid 不能为空');
}
$merchant = $this->merchantService->ensureMerchantEnabled($merchantId);
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
if (!$credential || (int) $credential->status !== AuthConstant::CREDENTIAL_STATUS_ENABLED) {
throw new ValidationException('商户 API 凭证未开通');
}
$publicKey = trim((string) ($credential->merchant_public_key ?? ''));
if ($publicKey === '') {
throw new ValidationException('商户 RSA 公钥未配置');
}
if ($verifySignature) {
$timestamp = (int) ($payload['timestamp'] ?? 0);
if ($timestamp <= 0 || abs(time() - $timestamp) > (int) config('epay.v2.timestamp_ttl', 300)) {
throw new ValidationException('timestamp 校验失败');
}
$sign = trim((string) ($payload['sign'] ?? ''));
if ($sign === '') {
throw new ValidationException('sign 不能为空');
}
$signType = $this->signerManager->normalizeSignType((string) ($payload['sign_type'] ?? AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA));
$verifyPayload = $payload;
unset($verifyPayload['sign'], $verifyPayload['sign_type']);
if (!$this->signerManager->verify($verifyPayload, $signType, $sign, $publicKey)) {
throw new ValidationException('签名验证失败');
}
}
return $merchant;
}
/**
* 响应签名。
*
* @param array<string, mixed> $data 响应数据
* @return array<string, mixed>
*/
private function signResponse(array $data): array
{
$data['timestamp'] = (string) ($data['timestamp'] ?? time());
$data['sign_type'] = $this->resolveResponseSignType();
$privateKey = trim((string) config('epay.v2.platform_private_key', ''));
if ($privateKey === '') {
throw new ValidationException('平台 RSA 私钥未配置');
}
$signParams = $data;
unset($signParams['sign'], $signParams['sign_type']);
$data['sign'] = $this->signerManager->sign($signParams, $data['sign_type'], $privateKey);
return $data;
}
/**
* 解析响应签名类型。
*
* 响应始终回写文档约定的规范值,避免把内部别名暴露给商户。
*
* @return string
*/
private function resolveResponseSignType(): string
{
$signType = $this->signerManager->normalizeSignType((string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA));
return match ($signType) {
AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA => AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
AuthConstant::API_SIGN_NAME_MD5 => AuthConstant::API_SIGN_NAME_MD5,
default => AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA,
};
}
/**
* 规范化错误信息。
*
* @param Throwable $e 异常
* @return string
*/
private function normalizeErrorMessage(Throwable $e): string
{
return $e->getMessage() ?: '请求失败';
}
/**
* V2 协议成功码。
*
* @return int
*/
private function successCode(): int
{
return 0;
}
/**
* 解析 V2 失败码。
*
* 文档只约定 `0` 为成功,其它值为失败;这里优先保留异常业务码,缺失时回退到 `1`。
*
* @param Throwable|null $e 异常对象
* @return int
*/
private function resolveFailureCode(?Throwable $e = null): int
{
$code = (int) ($e?->getCode() ?? 0);
return $code === $this->successCode() ? 1 : $code;
}
/**
* 金额字符串转分。
*
* @param string $money 金额字符串
* @return int
*/
private function parseMoneyToAmount(string $money): int
{
$money = trim($money);
if ($money === '' || !preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) {
return 0;
}
[$integer, $fraction] = array_pad(explode('.', $money, 2), 2, '');
$fraction = str_pad($fraction, 2, '0');
return ((int) $integer) * 100 + (int) substr($fraction, 0, 2);
}
/**
* 解析数字值。
*
* @param mixed $value 值
* @return string
*/
private function stringifyValue(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_array($value) || is_object($value)) {
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $json !== false ? $json : '';
}
return trim((string) $value);
}
/**
* 获取当前日期。
*
* @return string
*/
private function nowDate(): string
{
return FormatHelper::timestamp(time(), 'Y-m-d');
}
/**
* 获取昨日日期。
*
* @return string
*/
private function yesterdayDate(): string
{
return FormatHelper::timestamp(strtotime('-1 day'), 'Y-m-d');
}
/**
* 解析插件退款渠道单号。
*
* @param array $pluginResult 插件结果
* @return string
*/
private function resolveRefundChannelNo(array $pluginResult): string
{
foreach (['chan_refund_no', 'refund_no', 'trade_no', 'out_request_no'] as $key) {
$value = $this->stringifyValue($pluginResult[$key] ?? '');
if ($value !== '') {
return $value;
}
}
return '';
}
/**
* 判断插件是否成功。
*
* @param array $pluginResult 插件结果
* @return bool
*/
private function isPluginSuccess(array $pluginResult): bool
{
return !array_key_exists('success', $pluginResult) || (bool) $pluginResult['success'];
}
/**
* 按支付载体生成浏览器响应。
*
* 页面跳转支付允许直接返回渠道跳转页或 HTML其余情况回到平台支付页承载。
*
* @param array<string, mixed> $attempt 支付尝试结果
* @return Response
*/
private function buildBrowserSubmitResponse(array $attempt): Response
{
$payParams = (array) ($attempt['pay_params'] ?? []);
$paymentResult = (array) ($attempt['payment_result'] ?? []);
$payType = strtolower(trim((string) ($payParams['type'] ?? $paymentResult['pay_type'] ?? '')));
$paymentPageUrl = (string) ($attempt['payment_page_url'] ?? '');
if (in_array($payType, ['jump', 'url', 'web', 'h5'], true)) {
$jumpUrl = $this->resolveBrowserPayUrl($payParams);
if ($jumpUrl !== '') {
return redirect($jumpUrl);
}
}
if (in_array($payType, ['html', 'form'], true)) {
$html = $this->resolveBrowserHtml($payParams);
if ($html !== '') {
return response($html, 200, [
'Content-Type' => 'text/html; charset=utf-8',
]);
}
}
if ($paymentPageUrl === '') {
throw new ValidationException('支付页跳转地址生成失败');
}
return redirect($paymentPageUrl);
}
/**
* 构建支付页地址。
*
* @param string $payNo 支付单号
* @return string
*/
private function buildPaymentPageUrl(string $payNo): string
{
return rtrim((string) sys_config('site_url'), '/') . '/payment/' . rawurlencode($payNo);
}
/**
* 提取浏览器跳转地址。
*
* @param array<string, mixed> $payParams 支付参数
* @return string
*/
private function resolveBrowserPayUrl(array $payParams): string
{
foreach (['payurl', 'pay_url', 'url', 'redirect_url', 'mweb_url'] as $key) {
$value = $this->stringifyValue($payParams[$key] ?? '');
if ($value !== '') {
return $value;
}
}
return '';
}
/**
* 提取浏览器可直接渲染的 HTML。
*
* @param array<string, mixed> $payParams 支付参数
* @return string
*/
private function resolveBrowserHtml(array $payParams): string
{
foreach (['html', 'html_form', 'form_html'] as $key) {
$value = $payParams[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return '';
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace app\service\payment\epay;
/**
* ePay MD5 签名实现。
*/
class Md5Signer extends EpaySignerAbstract implements EpaySignerInterface
{
/**
* 生成 MD5 签名。
*
* @param array<string, mixed> $params 待签名参数
* @param string $key 密钥
* @return string 签名结果
*/
public function sign(array $params, string $key): string
{
$content = $this->buildContent($params);
return md5($content . $key);
}
/**
* 验证 MD5 签名。
*
* @param array<string, mixed> $params 待验签参数
* @param string $sign 签名值
* @param string $key 密钥
* @return bool 是否通过
*/
public function verify(array $params, string $sign, string $key): bool
{
$expected = $this->sign($params, $key);
return hash_equals(strtolower($expected), strtolower(trim($sign)));
}
}

Some files were not shown because too many files have changed in this diff Show More