mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-04-21 01:14:31 +08:00
重构初始化
This commit is contained in:
667
app/command/EpayMapiTest.php
Normal file
667
app/command/EpayMapiTest.php
Normal file
@@ -0,0 +1,667 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\http\api\controller\adapter\EpayController;
|
||||
use app\model\merchant\Merchant;
|
||||
use app\model\merchant\MerchantApiCredential;
|
||||
use app\model\payment\PaymentChannel;
|
||||
use app\model\payment\PaymentPollGroup;
|
||||
use app\model\payment\PaymentType;
|
||||
use app\repository\merchant\base\MerchantRepository;
|
||||
use app\repository\merchant\credential\MerchantApiCredentialRepository;
|
||||
use app\repository\payment\config\PaymentChannelRepository;
|
||||
use app\repository\payment\config\PaymentPollGroupBindRepository;
|
||||
use app\repository\payment\config\PaymentPollGroupChannelRepository;
|
||||
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 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;
|
||||
use support\Request;
|
||||
|
||||
#[AsCommand('epay:mapi', '运行 ePay mapi 兼容接口烟雾测试')]
|
||||
class EpayMapiTest extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDescription('自动读取真实商户、路由和插件配置,测试 ePay mapi 是否正常调用并返回结果。')
|
||||
->addOption('live', null, InputOption::VALUE_NONE, '使用真实数据库并发起实际 mapi 调用')
|
||||
->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('out-trade-no', null, InputOption::VALUE_OPTIONAL, '商户订单号,默认自动生成');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$output->writeln('<info>epay mapi 烟雾测试</info>');
|
||||
|
||||
if (!$this->optionBool($input, 'live', false)) {
|
||||
$this->ensureDependencies();
|
||||
$output->writeln('<info>[通过]</info> 依赖检查正常,使用 --live 才会真正发起 mapi 请求。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$typeCode = trim($this->optionString($input, 'type', 'alipay'));
|
||||
$money = $this->normalizeMoney($this->optionString($input, 'money', '1.00'));
|
||||
$device = $this->normalizeDevice($this->optionString($input, 'device', 'pc'));
|
||||
$merchantIdOption = $this->optionInt($input, 'merchant-id', 0);
|
||||
$merchantNoOption = trim($this->optionString($input, 'merchant-no', ''));
|
||||
$outTradeNo = $this->buildMerchantOrderNo(trim($this->optionString($input, 'out-trade-no', '')));
|
||||
|
||||
$context = $this->discoverContext($merchantIdOption, $merchantNoOption, $typeCode);
|
||||
$merchant = $context['merchant'];
|
||||
$credential = $context['credential'];
|
||||
$paymentType = $context['payment_type'];
|
||||
$route = $context['route'];
|
||||
$siteUrl = $this->resolveSiteUrl();
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'商户: id=%d no=%s name=%s group_id=%d',
|
||||
(int) $merchant->id,
|
||||
(string) $merchant->merchant_no,
|
||||
(string) $merchant->merchant_name,
|
||||
(int) $merchant->group_id
|
||||
));
|
||||
$output->writeln(sprintf(
|
||||
'接口凭证: %s',
|
||||
FormatHelper::maskCredentialValue((string) $credential->api_key)
|
||||
));
|
||||
$output->writeln(sprintf(
|
||||
'支付方式: %s(%d) 金额: %s 设备: %s',
|
||||
(string) $paymentType->code,
|
||||
(int) $paymentType->id,
|
||||
$money,
|
||||
$device
|
||||
));
|
||||
$this->writeRouteSnapshot($output, $route);
|
||||
|
||||
$payload = $this->buildPayload(
|
||||
merchant: $merchant,
|
||||
credential: $credential,
|
||||
paymentType: $paymentType,
|
||||
merchantOrderNo: $outTradeNo,
|
||||
money: $money,
|
||||
device: $device,
|
||||
siteUrl: $siteUrl
|
||||
);
|
||||
$controller = $this->resolve(EpayController::class);
|
||||
$response = $controller->mapi($this->buildRequest($payload));
|
||||
$responseData = $this->decodeResponse($response->rawBody());
|
||||
$orderSnapshot = $this->loadOrderSnapshot((int) $merchant->id, $outTradeNo);
|
||||
|
||||
$this->writeAttempt($output, $payload, $responseData, $orderSnapshot);
|
||||
|
||||
$status = $this->classifyAttempt($responseData, $orderSnapshot);
|
||||
return $status === 'pass' ? self::SUCCESS : self::FAILURE;
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>[失败]</error> ' . $this->formatThrowable($e));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureDependencies(): void
|
||||
{
|
||||
$this->resolve(EpayController::class);
|
||||
$this->resolve(MerchantRepository::class);
|
||||
$this->resolve(MerchantApiCredentialRepository::class);
|
||||
$this->resolve(PaymentTypeRepository::class);
|
||||
$this->resolve(PaymentPollGroupBindRepository::class);
|
||||
$this->resolve(PaymentPollGroupRepository::class);
|
||||
$this->resolve(PaymentPollGroupChannelRepository::class);
|
||||
$this->resolve(PaymentChannelRepository::class);
|
||||
$this->resolve(BizOrderRepository::class);
|
||||
$this->resolve(PayOrderRepository::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{merchant:Merchant,credential:MerchantApiCredential,payment_type:PaymentType,route:array}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
$merchant = $this->pickMerchant($merchantIdOption, $merchantNoOption);
|
||||
$credential = $this->findMerchantCredential((int) $merchant->id);
|
||||
if (!$credential) {
|
||||
throw new RuntimeException('商户未开通有效接口凭证: ' . $merchant->merchant_no);
|
||||
}
|
||||
|
||||
$route = $this->buildRouteSnapshot((int) $merchant->group_id, (int) $paymentType->id);
|
||||
if ($route === null) {
|
||||
throw new RuntimeException('商户未配置可用路由: ' . $merchant->merchant_no);
|
||||
}
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'credential' => $credential,
|
||||
'payment_type' => $paymentType,
|
||||
'route' => $route,
|
||||
];
|
||||
}
|
||||
|
||||
private function pickMerchant(int $merchantIdOption, string $merchantNoOption): Merchant
|
||||
{
|
||||
/** @var MerchantRepository $merchantRepository */
|
||||
$merchantRepository = $this->resolve(MerchantRepository::class);
|
||||
|
||||
if ($merchantIdOption > 0) {
|
||||
$merchant = $merchantRepository->find($merchantIdOption);
|
||||
if (!$merchant || (int) $merchant->status !== 1) {
|
||||
throw new RuntimeException('指定商户不存在或未启用: ' . $merchantIdOption);
|
||||
}
|
||||
|
||||
if ($merchantNoOption !== '' && (string) $merchant->merchant_no !== $merchantNoOption) {
|
||||
throw new RuntimeException('merchant-id 和 merchant-no 不匹配。');
|
||||
}
|
||||
|
||||
return $merchant;
|
||||
}
|
||||
|
||||
if ($merchantNoOption !== '') {
|
||||
$merchant = $merchantRepository->findByMerchantNo($merchantNoOption);
|
||||
if (!$merchant || (int) $merchant->status !== 1) {
|
||||
throw new RuntimeException('指定商户不存在或未启用: ' . $merchantNoOption);
|
||||
}
|
||||
|
||||
return $merchant;
|
||||
}
|
||||
|
||||
$merchant = $merchantRepository->enabledList(['id', 'merchant_no', 'merchant_name', 'group_id', 'status'])->first();
|
||||
if (!$merchant) {
|
||||
throw new RuntimeException('未找到启用中的真实商户。');
|
||||
}
|
||||
|
||||
return $merchant;
|
||||
}
|
||||
|
||||
private function findMerchantCredential(int $merchantId): ?MerchantApiCredential
|
||||
{
|
||||
/** @var MerchantApiCredentialRepository $repository */
|
||||
$repository = $this->resolve(MerchantApiCredentialRepository::class);
|
||||
$credential = $repository->findByMerchantId($merchantId);
|
||||
if (!$credential || (int) $credential->status !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $credential;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{bind:mixed,poll_group:PaymentPollGroup,candidates:array<int,array<string,mixed>>}|null
|
||||
*/
|
||||
private function buildRouteSnapshot(int $merchantGroupId, int $payTypeId): ?array
|
||||
{
|
||||
/** @var PaymentPollGroupBindRepository $bindRepository */
|
||||
$bindRepository = $this->resolve(PaymentPollGroupBindRepository::class);
|
||||
/** @var PaymentPollGroupRepository $pollGroupRepository */
|
||||
$pollGroupRepository = $this->resolve(PaymentPollGroupRepository::class);
|
||||
/** @var PaymentPollGroupChannelRepository $pollGroupChannelRepository */
|
||||
$pollGroupChannelRepository = $this->resolve(PaymentPollGroupChannelRepository::class);
|
||||
/** @var PaymentChannelRepository $channelRepository */
|
||||
$channelRepository = $this->resolve(PaymentChannelRepository::class);
|
||||
|
||||
$bind = $bindRepository->findActiveByMerchantGroupAndPayType($merchantGroupId, $payTypeId);
|
||||
if (!$bind) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pollGroup = $pollGroupRepository->find((int) $bind->poll_group_id);
|
||||
if (!$pollGroup || (int) $pollGroup->status !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidateRows = $pollGroupChannelRepository->listByPollGroupId((int) $pollGroup->id);
|
||||
if ($candidateRows->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$channelIds = $candidateRows->pluck('channel_id')->all();
|
||||
$channels = $channelRepository->query()
|
||||
->whereIn('id', $channelIds)
|
||||
->where('status', 1)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$candidates = [];
|
||||
foreach ($candidateRows as $row) {
|
||||
$channel = $channels->get((int) $row->channel_id);
|
||||
if (!$channel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) $channel->pay_type_id !== $payTypeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = [
|
||||
'channel' => $channel,
|
||||
'poll_group_channel' => $row,
|
||||
];
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'bind' => $bind,
|
||||
'poll_group' => $pollGroup,
|
||||
'candidates' => $candidates,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildPayload(
|
||||
Merchant $merchant,
|
||||
MerchantApiCredential $credential,
|
||||
PaymentType $paymentType,
|
||||
string $merchantOrderNo,
|
||||
string $money,
|
||||
string $device,
|
||||
string $siteUrl
|
||||
): array {
|
||||
$siteUrl = rtrim($siteUrl, '/');
|
||||
$payload = [
|
||||
'pid' => (int) $merchant->id,
|
||||
'key' => (string) $credential->api_key,
|
||||
'type' => (string) $paymentType->code,
|
||||
'out_trade_no' => $merchantOrderNo,
|
||||
'notify_url' => $siteUrl . '/epay/mapi/notify',
|
||||
'return_url' => $siteUrl . '/epay/mapi/return',
|
||||
'name' => trim(sprintf('mpay epay mapi smoke %s', (string) $merchant->merchant_name)),
|
||||
'money' => $money,
|
||||
'clientip' => '127.0.0.1',
|
||||
'device' => $device,
|
||||
'sign_type' => 'MD5',
|
||||
];
|
||||
$payload['sign'] = $this->signPayload($payload, (string) $credential->api_key);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function classifyAttempt(array $responseData, array $orderSnapshot): string
|
||||
{
|
||||
$responseCode = (int) ($responseData['code'] ?? 0);
|
||||
$payOrder = $orderSnapshot['pay_order'] ?? null;
|
||||
$bizOrder = $orderSnapshot['biz_order'] ?? null;
|
||||
|
||||
if ($responseCode !== 1) {
|
||||
return $payOrder ? 'fail' : 'skip';
|
||||
}
|
||||
|
||||
return ($payOrder && $bizOrder) ? 'pass' : 'fail';
|
||||
}
|
||||
|
||||
private function writeRouteSnapshot(OutputInterface $output, array $route): void
|
||||
{
|
||||
/** @var PaymentPollGroup $pollGroup */
|
||||
$pollGroup = $route['poll_group'];
|
||||
$candidates = $route['candidates'];
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'路由: group_id=%d group_name=%s mode=%s',
|
||||
(int) $pollGroup->id,
|
||||
(string) $pollGroup->group_name,
|
||||
$this->routeModeLabel((int) $pollGroup->route_mode)
|
||||
));
|
||||
$output->writeln(sprintf(' 候选通道: %d 个', count($candidates)));
|
||||
foreach ($candidates as $item) {
|
||||
/** @var PaymentChannel $channel */
|
||||
$channel = $item['channel'];
|
||||
$pollGroupChannel = $item['poll_group_channel'];
|
||||
$output->writeln(sprintf(
|
||||
' - channel_id=%d name=%s default=%s sort_no=%d weight=%d mode=%s pay_type_id=%d plugin=%s',
|
||||
(int) $channel->id,
|
||||
(string) $channel->name,
|
||||
(int) $pollGroupChannel->is_default === 1 ? 'yes' : 'no',
|
||||
(int) $pollGroupChannel->sort_no,
|
||||
(int) $pollGroupChannel->weight,
|
||||
$this->channelModeLabel((int) $channel->channel_mode),
|
||||
(int) $channel->pay_type_id,
|
||||
(string) $channel->plugin_code
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function writeAttempt(OutputInterface $output, array $payload, array $responseData, array $orderSnapshot): void
|
||||
{
|
||||
$status = $this->classifyAttempt($responseData, $orderSnapshot);
|
||||
$label = match ($status) {
|
||||
'pass' => '<info>[通过]</info>',
|
||||
'skip' => '<comment>[跳过]</comment>',
|
||||
default => '<error>[失败]</error>',
|
||||
};
|
||||
$payOrder = $orderSnapshot['pay_order'] ?? [];
|
||||
$bizOrder = $orderSnapshot['biz_order'] ?? [];
|
||||
$channel = $orderSnapshot['channel'] ?? [];
|
||||
$paymentType = $orderSnapshot['payment_type'] ?? [];
|
||||
|
||||
$output->writeln(sprintf('%s mapi - out_trade_no=%s', $label, $payload['out_trade_no']));
|
||||
$output->writeln(sprintf(
|
||||
' 请求: pid=%d type=%s money=%s device=%s clientip=%s',
|
||||
(int) $payload['pid'],
|
||||
(string) $payload['type'],
|
||||
(string) $payload['money'],
|
||||
(string) $payload['device'],
|
||||
(string) $payload['clientip']
|
||||
));
|
||||
$output->writeln(sprintf(
|
||||
' 响应: code=%s msg=%s',
|
||||
(string) ($responseData['code'] ?? ''),
|
||||
(string) ($responseData['msg'] ?? '')
|
||||
));
|
||||
foreach (['trade_no', 'payurl', 'origin_payurl', 'qrcode', 'urlscheme'] as $key) {
|
||||
if (!isset($responseData[$key]) || $responseData[$key] === '') {
|
||||
continue;
|
||||
}
|
||||
$output->writeln(sprintf(' 返回: %s=%s', $key, $this->stringifyValue($responseData[$key])));
|
||||
}
|
||||
|
||||
if (!$bizOrder || !$payOrder) {
|
||||
$output->writeln(' 订单: 未创建或未查到业务单/支付单');
|
||||
return;
|
||||
}
|
||||
|
||||
$output->writeln(sprintf(
|
||||
' 业务单: biz_no=%s status=%s active_pay_no=%s attempt_count=%d',
|
||||
(string) ($bizOrder['biz_no'] ?? ''),
|
||||
$this->orderStatusLabel((int) ($bizOrder['status'] ?? 0)),
|
||||
(string) ($bizOrder['active_pay_no'] ?? ''),
|
||||
(int) ($bizOrder['attempt_count'] ?? 0)
|
||||
));
|
||||
$output->writeln(sprintf(
|
||||
' 支付单: pay_no=%s status=%s channel_id=%d channel=%s plugin=%s pay_type=%s',
|
||||
(string) ($payOrder['pay_no'] ?? ''),
|
||||
$this->orderStatusLabel((int) ($payOrder['status'] ?? 0)),
|
||||
(int) ($payOrder['channel_id'] ?? 0),
|
||||
(string) ($channel['name'] ?? ''),
|
||||
(string) ($payOrder['plugin_code'] ?? ''),
|
||||
(string) ($paymentType['code'] ?? '')
|
||||
));
|
||||
$output->writeln(sprintf(
|
||||
' 支付单状态: channel_request_no=%s channel_order_no=%s channel_trade_no=%s',
|
||||
(string) ($payOrder['channel_request_no'] ?? ''),
|
||||
(string) ($payOrder['channel_order_no'] ?? ''),
|
||||
(string) ($payOrder['channel_trade_no'] ?? '')
|
||||
));
|
||||
$output->writeln(sprintf(
|
||||
' 失败信息: code=%s msg=%s',
|
||||
(string) ($payOrder['channel_error_code'] ?? ''),
|
||||
(string) ($payOrder['channel_error_msg'] ?? '')
|
||||
));
|
||||
|
||||
$extJson = (array) ($payOrder['ext_json'] ?? []);
|
||||
$summary = $this->summarizePayParamsSnapshot((array) ($extJson['pay_params_snapshot'] ?? []));
|
||||
if ($summary !== []) {
|
||||
$output->writeln(' 插件返回:');
|
||||
$output->writeln(' ' . $this->formatJson($summary));
|
||||
}
|
||||
}
|
||||
|
||||
private function summarizePayParamsSnapshot(array $snapshot): array
|
||||
{
|
||||
if ($snapshot === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$summary = ['type' => (string) ($snapshot['type'] ?? '')];
|
||||
if (isset($snapshot['pay_product'])) {
|
||||
$summary['pay_product'] = (string) $snapshot['pay_product'];
|
||||
}
|
||||
if (isset($snapshot['pay_action'])) {
|
||||
$summary['pay_action'] = (string) $snapshot['pay_action'];
|
||||
}
|
||||
|
||||
switch ((string) ($snapshot['type'] ?? '')) {
|
||||
case 'form':
|
||||
$html = $this->stringifyValue($snapshot['html'] ?? '');
|
||||
$summary['html_length'] = strlen($html);
|
||||
$summary['html_head'] = $this->limitString($this->normalizeWhitespace($html), 160);
|
||||
break;
|
||||
case 'qrcode':
|
||||
$summary['qrcode_url'] = $this->stringifyValue($snapshot['qrcode_url'] ?? $snapshot['qrcode_data'] ?? '');
|
||||
break;
|
||||
case 'urlscheme':
|
||||
$summary['urlscheme'] = $this->stringifyValue($snapshot['urlscheme'] ?? $snapshot['order_str'] ?? '');
|
||||
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'])) {
|
||||
$summary['raw_keys'] = array_values(array_map('strval', array_keys($snapshot['raw'])));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function routeModeLabel(int $routeMode): string
|
||||
{
|
||||
return RouteConstant::routeModeMap()[$routeMode] ?? '未知';
|
||||
}
|
||||
|
||||
private function channelModeLabel(int $channelMode): string
|
||||
{
|
||||
return RouteConstant::channelModeMap()[$channelMode] ?? '未知';
|
||||
}
|
||||
|
||||
private function orderStatusLabel(int $status): string
|
||||
{
|
||||
return TradeConstant::orderStatusMap()[$status] ?? '未知';
|
||||
}
|
||||
|
||||
private function buildMerchantOrderNo(string $base): string
|
||||
{
|
||||
$base = trim($base);
|
||||
if ($base !== '') {
|
||||
return substr($base, 0, 64);
|
||||
}
|
||||
|
||||
return 'EPAY-MAPI-' . FormatHelper::timestamp(time(), 'YmdHis') . random_int(1000, 9999);
|
||||
}
|
||||
|
||||
private function signPayload(array $payload, string $key): string
|
||||
{
|
||||
$params = $payload;
|
||||
unset($params['sign'], $params['sign_type'], $params['key']);
|
||||
foreach ($params as $paramKey => $paramValue) {
|
||||
if ($paramValue === '' || $paramValue === null) {
|
||||
unset($params[$paramKey]);
|
||||
}
|
||||
}
|
||||
|
||||
ksort($params);
|
||||
$query = [];
|
||||
foreach ($params as $paramKey => $paramValue) {
|
||||
$query[] = $paramKey . '=' . $paramValue;
|
||||
}
|
||||
|
||||
return md5(implode('&', $query) . $key);
|
||||
}
|
||||
|
||||
private function buildRequest(array $payload): 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;
|
||||
|
||||
$rawRequest = implode("\r\n", [
|
||||
'POST /mapi.php HTTP/1.1',
|
||||
'Host: ' . $hostHeader,
|
||||
'Content-Type: application/x-www-form-urlencoded; charset=UTF-8',
|
||||
'Content-Length: ' . strlen($body),
|
||||
'Connection: close',
|
||||
'',
|
||||
$body,
|
||||
]);
|
||||
|
||||
return new Request($rawRequest);
|
||||
}
|
||||
|
||||
private function loadOrderSnapshot(int $merchantId, string $merchantOrderNo): array
|
||||
{
|
||||
/** @var BizOrderRepository $bizOrderRepository */
|
||||
$bizOrderRepository = $this->resolve(BizOrderRepository::class);
|
||||
/** @var PayOrderRepository $payOrderRepository */
|
||||
$payOrderRepository = $this->resolve(PayOrderRepository::class);
|
||||
/** @var PaymentChannelRepository $channelRepository */
|
||||
$channelRepository = $this->resolve(PaymentChannelRepository::class);
|
||||
/** @var PaymentTypeRepository $typeRepository */
|
||||
$typeRepository = $this->resolve(PaymentTypeRepository::class);
|
||||
|
||||
$bizOrder = $bizOrderRepository->findByMerchantAndOrderNo($merchantId, $merchantOrderNo);
|
||||
$payOrder = $bizOrder ? $payOrderRepository->findLatestByBizNo((string) $bizOrder->biz_no) : null;
|
||||
$channel = $payOrder ? $channelRepository->find((int) $payOrder->channel_id) : null;
|
||||
$paymentType = $payOrder ? $typeRepository->find((int) $payOrder->pay_type_id) : null;
|
||||
|
||||
return [
|
||||
'biz_order' => $bizOrder ? $bizOrder->toArray() : null,
|
||||
'pay_order' => $payOrder ? $payOrder->toArray() : null,
|
||||
'channel' => $channel ? $channel->toArray() : null,
|
||||
'payment_type' => $paymentType ? $paymentType->toArray() : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveSiteUrl(): string
|
||||
{
|
||||
$siteUrl = trim((string) sys_config('site_url'));
|
||||
return $siteUrl !== '' ? rtrim($siteUrl, '/') : 'http://localhost:8787';
|
||||
}
|
||||
|
||||
private function normalizeMoney(string $money): string
|
||||
{
|
||||
$money = trim($money);
|
||||
if ($money === '') {
|
||||
return '1.00';
|
||||
}
|
||||
|
||||
if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) {
|
||||
throw new RuntimeException('money 参数不合法: ' . $money);
|
||||
}
|
||||
|
||||
return number_format((float) $money, 2, '.', '');
|
||||
}
|
||||
|
||||
private function normalizeDevice(string $device): string
|
||||
{
|
||||
$device = strtolower(trim($device));
|
||||
return $device !== '' ? $device : 'pc';
|
||||
}
|
||||
|
||||
private function decodeResponse(string $body): array
|
||||
{
|
||||
$decoded = json_decode($body, true);
|
||||
return is_array($decoded) ? $decoded : ['raw' => $body];
|
||||
}
|
||||
|
||||
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_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return $json !== false ? $json : '';
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
private function limitString(string $value, int $length): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return strlen($value) <= $length ? $value : substr($value, 0, $length) . '...';
|
||||
}
|
||||
|
||||
private function normalizeWhitespace(string $value): string
|
||||
{
|
||||
return preg_replace('/\s+/', ' ', trim($value)) ?: '';
|
||||
}
|
||||
|
||||
private function formatJson(mixed $value): string
|
||||
{
|
||||
return FormatHelper::json($value);
|
||||
}
|
||||
|
||||
private function formatThrowable(\Throwable $e): string
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private function optionInt(InputInterface $input, string $name, int $default = 0): int
|
||||
{
|
||||
$value = $input->getOption($name);
|
||||
return is_numeric($value) ? (int) $value : $default;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private function resolve(string $class): object
|
||||
{
|
||||
try {
|
||||
$instance = container_make($class, []);
|
||||
} catch (\Throwable $e) {
|
||||
throw new RuntimeException("无法解析 {$class}: " . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
if (!is_object($instance)) {
|
||||
throw new RuntimeException("解析后的 {$class} 不是对象。");
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
510
app/command/MpayTest.php
Normal file
510
app/command/MpayTest.php
Normal file
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\service\account\funds\MerchantAccountService;
|
||||
use app\service\merchant\MerchantService;
|
||||
use app\service\payment\order\PayOrderService;
|
||||
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;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand('mpay:test', '运行支付、退款、清结算、余额和追踪烟雾测试')]
|
||||
class MpayTest extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDescription('运行支付、退款、清结算、余额和追踪烟雾测试。')
|
||||
->addOption('payment', null, InputOption::VALUE_NONE, '仅运行支付检查')
|
||||
->addOption('refund', null, InputOption::VALUE_NONE, '仅运行退款检查')
|
||||
->addOption('settlement', null, InputOption::VALUE_NONE, '仅运行清结算检查')
|
||||
->addOption('balance', null, InputOption::VALUE_NONE, '仅运行余额检查')
|
||||
->addOption('trace', null, InputOption::VALUE_NONE, '仅运行追踪检查')
|
||||
->addOption('all', null, InputOption::VALUE_NONE, '运行全部检查')
|
||||
->addOption('live', null, InputOption::VALUE_NONE, '在提供测试数据时运行真实数据库检查');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$cases = $this->resolveCases($input);
|
||||
$live = (bool) $input->getOption('live');
|
||||
|
||||
$output->writeln('<info>mpay 烟雾测试</info>');
|
||||
$output->writeln('模式: ' . ($live ? '真实数据' : '依赖连通性'));
|
||||
$output->writeln('测试项: ' . implode(', ', $cases));
|
||||
|
||||
$summary = [];
|
||||
foreach ($cases as $case) {
|
||||
$result = match ($case) {
|
||||
'payment' => $this->checkPayment($live),
|
||||
'refund' => $this->checkRefund($live),
|
||||
'settlement' => $this->checkSettlement($live),
|
||||
'balance' => $this->checkBalance($live),
|
||||
'trace' => $this->checkTrace($live),
|
||||
default => ['status' => 'skip', 'message' => '未知测试项'],
|
||||
};
|
||||
|
||||
$summary[] = $result['status'];
|
||||
$this->writeResult($output, strtoupper($case), $result['status'], $result['message']);
|
||||
}
|
||||
|
||||
$failed = count(array_filter($summary, static fn (string $status) => $status === 'fail'));
|
||||
$skipped = count(array_filter($summary, static fn (string $status) => $status === 'skip'));
|
||||
$passed = count(array_filter($summary, static fn (string $status) => $status === 'pass'));
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'<info>汇总</info>: %d 通过, %d 跳过, %d 失败',
|
||||
$passed,
|
||||
$skipped,
|
||||
$failed
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据命令行选项解析需要执行的测试项。
|
||||
*/
|
||||
private function resolveCases(InputInterface $input): array
|
||||
{
|
||||
$selected = [];
|
||||
foreach (['payment', 'refund', 'settlement', 'balance', 'trace'] as $case) {
|
||||
if ((bool) $input->getOption($case)) {
|
||||
$selected[] = $case;
|
||||
}
|
||||
}
|
||||
|
||||
if ((bool) $input->getOption('all') || empty($selected)) {
|
||||
return ['payment', 'refund', 'settlement', 'balance', 'trace'];
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查支付链路。
|
||||
*/
|
||||
private function checkPayment(bool $live): array
|
||||
{
|
||||
try {
|
||||
$service = $this->resolve(PayOrderService::class);
|
||||
$this->ensureMethod($service, 'preparePayAttempt');
|
||||
$this->ensureMethod($service, 'timeoutPayOrder');
|
||||
|
||||
if (!$live) {
|
||||
return ['status' => 'pass', 'message' => '服务依赖连通性正常'];
|
||||
}
|
||||
|
||||
$merchantId = $this->envInt('MPAY_TEST_PAYMENT_MERCHANT_ID');
|
||||
$payTypeId = $this->envInt('MPAY_TEST_PAYMENT_TYPE_ID');
|
||||
$payAmount = $this->envInt('MPAY_TEST_PAYMENT_AMOUNT');
|
||||
$merchantOrderNo = $this->envString('MPAY_TEST_PAYMENT_ORDER_NO', $this->generateTestNo('PAY-TEST-'));
|
||||
|
||||
if ($merchantId <= 0 || $payTypeId <= 0 || $payAmount <= 0) {
|
||||
return ['status' => 'skip', 'message' => '缺少 MPAY_TEST_PAYMENT_* 测试配置'];
|
||||
}
|
||||
|
||||
$result = $service->preparePayAttempt([
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_order_no' => $merchantOrderNo,
|
||||
'pay_type_id' => $payTypeId,
|
||||
'pay_amount' => $payAmount,
|
||||
'subject' => $this->envString('MPAY_TEST_PAYMENT_SUBJECT', 'mpay smoke payment'),
|
||||
'body' => $this->envString('MPAY_TEST_PAYMENT_BODY', 'mpay smoke payment'),
|
||||
'ext_json' => $this->envJson('MPAY_TEST_PAYMENT_EXT_JSON', []),
|
||||
]);
|
||||
|
||||
$payOrder = $result['pay_order'];
|
||||
$selectedChannel = $result['route']['selected_channel']['channel'] ?? null;
|
||||
$message = sprintf(
|
||||
'已创建支付单 pay_no=%s biz_no=%s channel_id=%s',
|
||||
(string) $payOrder->pay_no,
|
||||
(string) $result['biz_order']->biz_no,
|
||||
$selectedChannel ? (string) $selectedChannel->id : ''
|
||||
);
|
||||
|
||||
if ($this->envBool('MPAY_TEST_PAYMENT_MARK_TIMEOUT', false)) {
|
||||
$service->timeoutPayOrder((string) $payOrder->pay_no, [
|
||||
'timeout_at' => $this->envString('MPAY_TEST_PAYMENT_TIMEOUT_AT', FormatHelper::timestamp(time())),
|
||||
'reason' => $this->envString('MPAY_TEST_PAYMENT_TIMEOUT_REASON', 'mpay smoke timeout'),
|
||||
]);
|
||||
$message .= ', 已标记超时';
|
||||
} elseif ($this->envBool('MPAY_TEST_PAYMENT_MARK_SUCCESS', false)) {
|
||||
$service->markPaySuccess((string) $payOrder->pay_no, [
|
||||
'fee_actual_amount' => $this->envInt('MPAY_TEST_PAYMENT_FEE_AMOUNT', (int) $payOrder->fee_estimated_amount),
|
||||
'channel_trade_no' => $this->envString('MPAY_TEST_PAYMENT_CHANNEL_TRADE_NO', $this->generateTestNo('CH-')),
|
||||
'channel_order_no' => $this->envString('MPAY_TEST_PAYMENT_CHANNEL_ORDER_NO', $this->generateTestNo('CO-')),
|
||||
]);
|
||||
$message .= ', 已标记成功';
|
||||
}
|
||||
|
||||
return ['status' => 'pass', 'message' => $message];
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'fail', 'message' => $this->formatThrowable($e)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查退款链路。
|
||||
*/
|
||||
private function checkRefund(bool $live): array
|
||||
{
|
||||
try {
|
||||
$service = $this->resolve(RefundService::class);
|
||||
$this->ensureMethod($service, 'createRefund');
|
||||
$this->ensureMethod($service, 'markRefundProcessing');
|
||||
$this->ensureMethod($service, 'retryRefund');
|
||||
$this->ensureMethod($service, 'markRefundFailed');
|
||||
|
||||
if (!$live) {
|
||||
return ['status' => 'pass', 'message' => '服务依赖连通性正常'];
|
||||
}
|
||||
|
||||
$payNo = $this->envString('MPAY_TEST_REFUND_PAY_NO');
|
||||
if ($payNo === '') {
|
||||
return ['status' => 'skip', 'message' => '缺少 MPAY_TEST_REFUND_PAY_NO 测试配置'];
|
||||
}
|
||||
|
||||
$refund = $service->createRefund([
|
||||
'pay_no' => $payNo,
|
||||
'merchant_refund_no' => $this->envString('MPAY_TEST_REFUND_NO', $this->generateTestNo('RFD-TEST-')),
|
||||
'reason' => $this->envString('MPAY_TEST_REFUND_REASON', 'mpay smoke refund'),
|
||||
'ext_json' => $this->envJson('MPAY_TEST_REFUND_EXT_JSON', []),
|
||||
]);
|
||||
|
||||
$message = '已创建退款单 refund_no=' . (string) $refund->refund_no;
|
||||
|
||||
if ($this->envBool('MPAY_TEST_REFUND_MARK_PROCESSING', false)) {
|
||||
$service->markRefundProcessing((string) $refund->refund_no, [
|
||||
'processing_at' => $this->envString('MPAY_TEST_REFUND_PROCESSING_AT', FormatHelper::timestamp(time())),
|
||||
]);
|
||||
$message .= ', 已标记处理中';
|
||||
} elseif ($this->envBool('MPAY_TEST_REFUND_MARK_RETRY', false)) {
|
||||
$service->retryRefund((string) $refund->refund_no, [
|
||||
'processing_at' => $this->envString('MPAY_TEST_REFUND_RETRY_AT', FormatHelper::timestamp(time())),
|
||||
]);
|
||||
$message .= ', 已标记重试';
|
||||
} 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'),
|
||||
]);
|
||||
$message .= ', 已标记失败';
|
||||
} elseif ($this->envBool('MPAY_TEST_REFUND_MARK_SUCCESS', false)) {
|
||||
$service->markRefundSuccess((string) $refund->refund_no, [
|
||||
'channel_refund_no' => $this->envString('MPAY_TEST_REFUND_CHANNEL_NO', $this->generateTestNo('CR-')),
|
||||
]);
|
||||
$message .= ', 已标记成功';
|
||||
}
|
||||
|
||||
return ['status' => 'pass', 'message' => $message];
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'fail', 'message' => $this->formatThrowable($e)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查清结算链路。
|
||||
*/
|
||||
private function checkSettlement(bool $live): array
|
||||
{
|
||||
try {
|
||||
$service = $this->resolve(SettlementService::class);
|
||||
$this->ensureMethod($service, 'createSettlementOrder');
|
||||
$this->ensureMethod($service, 'completeSettlement');
|
||||
$this->ensureMethod($service, 'failSettlement');
|
||||
|
||||
if (!$live) {
|
||||
return ['status' => 'pass', 'message' => '服务依赖连通性正常'];
|
||||
}
|
||||
|
||||
$merchantId = 0;
|
||||
$merchantGroupId = 0;
|
||||
$channelId = 0;
|
||||
$settleNo = $this->envString('MPAY_TEST_SETTLEMENT_NO', $this->generateTestNo('STL-TEST-'));
|
||||
$items = $this->envJson('MPAY_TEST_SETTLEMENT_ITEMS_JSON', []);
|
||||
|
||||
if (empty($items)) {
|
||||
$payNo = $this->envString('MPAY_TEST_SETTLEMENT_PAY_NO');
|
||||
if ($payNo !== '') {
|
||||
$payOrderRepository = $this->resolve(PayOrderRepository::class);
|
||||
$payOrder = $payOrderRepository->findByPayNo($payNo);
|
||||
if ($payOrder) {
|
||||
$items = [[
|
||||
'pay_no' => (string) $payOrder->pay_no,
|
||||
'refund_no' => '',
|
||||
'pay_amount' => (int) $payOrder->pay_amount,
|
||||
'fee_amount' => (int) $payOrder->fee_actual_amount,
|
||||
'refund_amount' => 0,
|
||||
'fee_reverse_amount' => 0,
|
||||
'net_amount' => max(0, (int) $payOrder->pay_amount - (int) $payOrder->fee_actual_amount),
|
||||
'item_status' => TradeConstant::SETTLEMENT_STATUS_PENDING,
|
||||
]];
|
||||
$merchantId = (int) $payOrder->merchant_id;
|
||||
$merchantGroupId = (int) $payOrder->merchant_group_id;
|
||||
$channelId = (int) $payOrder->channel_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($merchantId <= 0) {
|
||||
$merchantId = $this->envInt('MPAY_TEST_SETTLEMENT_MERCHANT_ID');
|
||||
}
|
||||
if ($merchantGroupId <= 0) {
|
||||
$merchantGroupId = $this->envInt('MPAY_TEST_SETTLEMENT_MERCHANT_GROUP_ID');
|
||||
}
|
||||
if ($channelId <= 0) {
|
||||
$channelId = $this->envInt('MPAY_TEST_SETTLEMENT_CHANNEL_ID');
|
||||
}
|
||||
|
||||
if ($merchantId <= 0 || $merchantGroupId <= 0 || $channelId <= 0) {
|
||||
return ['status' => 'skip', 'message' => '缺少 MPAY_TEST_SETTLEMENT_* 测试配置'];
|
||||
}
|
||||
|
||||
if (empty($items)) {
|
||||
$items = [[
|
||||
'pay_no' => '',
|
||||
'refund_no' => '',
|
||||
'pay_amount' => $this->envInt('MPAY_TEST_SETTLEMENT_GROSS_AMOUNT', 100),
|
||||
'fee_amount' => $this->envInt('MPAY_TEST_SETTLEMENT_FEE_AMOUNT', 0),
|
||||
'refund_amount' => $this->envInt('MPAY_TEST_SETTLEMENT_REFUND_AMOUNT', 0),
|
||||
'fee_reverse_amount' => $this->envInt('MPAY_TEST_SETTLEMENT_FEE_REVERSE_AMOUNT', 0),
|
||||
'net_amount' => $this->envInt('MPAY_TEST_SETTLEMENT_NET_AMOUNT', 100),
|
||||
'item_status' => TradeConstant::SETTLEMENT_STATUS_PENDING,
|
||||
]];
|
||||
}
|
||||
|
||||
$settlement = $service->createSettlementOrder([
|
||||
'settle_no' => $settleNo,
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
'channel_id' => $channelId,
|
||||
'cycle_type' => $this->envInt('MPAY_TEST_SETTLEMENT_CYCLE_TYPE', TradeConstant::SETTLEMENT_CYCLE_OTHER),
|
||||
'cycle_key' => $this->envString('MPAY_TEST_SETTLEMENT_CYCLE_KEY', FormatHelper::timestamp(time(), 'Y-m-d')),
|
||||
'accounted_amount' => $this->envInt('MPAY_TEST_SETTLEMENT_ACCOUNTED_AMOUNT', 0),
|
||||
'status' => TradeConstant::SETTLEMENT_STATUS_PENDING,
|
||||
'ext_json' => $this->envJson('MPAY_TEST_SETTLEMENT_EXT_JSON', []),
|
||||
], $items);
|
||||
|
||||
$message = '已创建清结算单 settle_no=' . (string) $settlement->settle_no;
|
||||
|
||||
if ($this->envBool('MPAY_TEST_SETTLEMENT_FAIL', false)) {
|
||||
$service->failSettlement(
|
||||
(string) $settlement->settle_no,
|
||||
$this->envString('MPAY_TEST_SETTLEMENT_FAIL_REASON', 'mpay smoke settlement fail')
|
||||
);
|
||||
$message .= ', 已标记失败';
|
||||
} elseif ($this->envBool('MPAY_TEST_SETTLEMENT_COMPLETE', false)) {
|
||||
$service->completeSettlement((string) $settlement->settle_no);
|
||||
$message .= ', 已完成入账';
|
||||
}
|
||||
|
||||
return ['status' => 'pass', 'message' => $message];
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'fail', 'message' => $this->formatThrowable($e)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查余额链路。
|
||||
*/
|
||||
private function checkBalance(bool $live): array
|
||||
{
|
||||
try {
|
||||
$accountService = $this->resolve(MerchantAccountService::class);
|
||||
$this->ensureMethod($accountService, 'getBalanceSnapshot');
|
||||
$this->resolve(MerchantService::class);
|
||||
|
||||
if (!$live) {
|
||||
return ['status' => 'pass', 'message' => '服务依赖连通性正常'];
|
||||
}
|
||||
|
||||
$merchantId = $this->envInt('MPAY_TEST_BALANCE_MERCHANT_ID');
|
||||
if ($merchantId <= 0) {
|
||||
$merchantNo = $this->envString('MPAY_TEST_BALANCE_MERCHANT_NO');
|
||||
if ($merchantNo !== '') {
|
||||
$merchantService = $this->resolve(MerchantService::class);
|
||||
$merchant = $merchantService->findEnabledMerchantByNo($merchantNo);
|
||||
$merchantId = (int) $merchant->id;
|
||||
}
|
||||
}
|
||||
|
||||
if ($merchantId <= 0) {
|
||||
return ['status' => 'skip', 'message' => '缺少 MPAY_TEST_BALANCE_* 测试配置'];
|
||||
}
|
||||
|
||||
$snapshot = $accountService->getBalanceSnapshot($merchantId);
|
||||
|
||||
return [
|
||||
'status' => 'pass',
|
||||
'message' => sprintf(
|
||||
'余额 merchant_id=%d 可用=%d 冻结=%d',
|
||||
(int) $snapshot['merchant_id'],
|
||||
(int) $snapshot['available_balance'],
|
||||
(int) $snapshot['frozen_balance']
|
||||
),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'fail', 'message' => $this->formatThrowable($e)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查统一追踪链路。
|
||||
*/
|
||||
private function checkTrace(bool $live): array
|
||||
{
|
||||
try {
|
||||
$service = $this->resolve(TradeTraceService::class);
|
||||
$this->ensureMethod($service, 'queryByTraceNo');
|
||||
|
||||
if (!$live) {
|
||||
return ['status' => 'pass', 'message' => '服务依赖连通性正常'];
|
||||
}
|
||||
|
||||
$traceNo = $this->envString('MPAY_TEST_TRACE_NO');
|
||||
if ($traceNo === '') {
|
||||
return ['status' => 'skip', 'message' => '缺少 MPAY_TEST_TRACE_NO 测试配置'];
|
||||
}
|
||||
|
||||
$result = $service->queryByTraceNo($traceNo);
|
||||
if (empty($result)) {
|
||||
return ['status' => 'fail', 'message' => '追踪结果为空'];
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'trace_no=%s 支付=%d 退款=%d 清结算=%d 流水=%d',
|
||||
(string) ($result['resolved_trace_no'] ?? $traceNo),
|
||||
count($result['pay_orders'] ?? []),
|
||||
count($result['refund_orders'] ?? []),
|
||||
count($result['settlement_orders'] ?? []),
|
||||
count($result['account_ledgers'] ?? [])
|
||||
);
|
||||
|
||||
return ['status' => 'pass', 'message' => $message];
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'fail', 'message' => $this->formatThrowable($e)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从容器中解析指定类实例。
|
||||
*/
|
||||
private function resolve(string $class): object
|
||||
{
|
||||
try {
|
||||
$instance = container_make($class, []);
|
||||
} catch (\Throwable $e) {
|
||||
throw new RuntimeException("无法解析 {$class}: " . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
if (!is_object($instance)) {
|
||||
throw new RuntimeException("解析后的 {$class} 不是对象。");
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查实例是否包含指定方法。
|
||||
*/
|
||||
private function ensureMethod(object $instance, string $method): void
|
||||
{
|
||||
if (!method_exists($instance, $method)) {
|
||||
throw new RuntimeException(sprintf('未找到方法 %s::%s。', $instance::class, $method));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取字符串环境变量。
|
||||
*/
|
||||
private function envString(string $key, string $default = ''): string
|
||||
{
|
||||
$value = env($key, $default);
|
||||
|
||||
return is_string($value) ? $value : (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取整数环境变量。
|
||||
*/
|
||||
private function envInt(string $key, int $default = 0): int
|
||||
{
|
||||
$value = env($key, null);
|
||||
|
||||
return is_numeric($value) ? (int) $value : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取布尔环境变量。
|
||||
*/
|
||||
private function envBool(string $key, bool $default = false): bool
|
||||
{
|
||||
$value = env($key, null);
|
||||
if ($value === null || $value === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$filtered = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
|
||||
|
||||
return $filtered === null ? $default : $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取结构化环境变量。
|
||||
*/
|
||||
private function envJson(string $key, array $default = []): array
|
||||
{
|
||||
$value = trim($this->envString($key));
|
||||
if ($value === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成测试编号。
|
||||
*/
|
||||
private function generateTestNo(string $prefix): string
|
||||
{
|
||||
return $prefix . FormatHelper::timestamp(time(), 'YmdHis') . random_int(1000, 9999);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将异常格式化为可读文本。
|
||||
*/
|
||||
private function formatThrowable(\Throwable $e): string
|
||||
{
|
||||
$data = method_exists($e, 'getData') ? $e->getData() : [];
|
||||
$suffix = $data ? ' ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : '';
|
||||
|
||||
return $e::class . ': ' . $e->getMessage() . $suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出单个测试项的执行结果。
|
||||
*/
|
||||
private function writeResult(OutputInterface $output, string $case, string $status, string $message): void
|
||||
{
|
||||
$label = match ($status) {
|
||||
'pass' => '<info>[通过]</info>',
|
||||
'skip' => '<comment>[跳过]</comment>',
|
||||
default => '<error>[失败]</error>',
|
||||
};
|
||||
|
||||
$output->writeln(sprintf('%s %s - %s', $label, $case, $message));
|
||||
}
|
||||
}
|
||||
|
||||
70
app/command/SystemConfigSync.php
Normal file
70
app/command/SystemConfigSync.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand('system:config-sync', '同步系统配置默认值到数据库')]
|
||||
class SystemConfigSync extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('同步 config/system_config.php 中定义的系统配置默认值到数据库。');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
try {
|
||||
/** @var SystemConfigDefinitionService $definitionService */
|
||||
$definitionService = container_make(SystemConfigDefinitionService::class, []);
|
||||
/** @var SystemConfigRepository $repository */
|
||||
$repository = container_make(SystemConfigRepository::class, []);
|
||||
/** @var SystemConfigRuntimeService $runtimeService */
|
||||
$runtimeService = container_make(SystemConfigRuntimeService::class, []);
|
||||
|
||||
$tabs = $definitionService->tabs();
|
||||
$written = 0;
|
||||
|
||||
foreach ($tabs as $tab) {
|
||||
$groupCode = (string) ($tab['key'] ?? '');
|
||||
foreach ((array) ($tab['rules'] ?? []) as $rule) {
|
||||
if (!is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$configKey = strtolower(trim((string) ($rule['field'] ?? '')));
|
||||
if ($configKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$repository->updateOrCreate(
|
||||
['config_key' => $configKey],
|
||||
[
|
||||
'group_code' => $groupCode,
|
||||
'config_value' => (string) ($rule['value'] ?? ''),
|
||||
]
|
||||
);
|
||||
|
||||
$written++;
|
||||
}
|
||||
}
|
||||
|
||||
$runtimeService->refresh();
|
||||
|
||||
$output->writeln(sprintf('<info>系统配置同步完成</info>,写入 %d 项。', $written));
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>系统配置同步失败:' . $e->getMessage() . '</error>');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user