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