add mq controller
This commit is contained in:
@@ -4,21 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Service\MqStatusService;
|
||||
use Hyperf\Command\Command as HyperfCommand;
|
||||
use Hyperf\Command\Annotation\Command;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use PhpAmqpLib\Connection\AMQPStreamConnection;
|
||||
use Hyperf\Contract\ConfigInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
#[Command]
|
||||
class AppMqStatus extends HyperfCommand
|
||||
{
|
||||
/**
|
||||
* 业务队列类型
|
||||
*/
|
||||
private const QUEUE_TYPES = ['orders', 'products', 'refunds', 'inventory'];
|
||||
|
||||
public function __construct(protected ContainerInterface $container)
|
||||
{
|
||||
parent::__construct('app:mq:status');
|
||||
@@ -59,116 +53,62 @@ class AppMqStatus extends HyperfCommand
|
||||
$this->line('');
|
||||
|
||||
try {
|
||||
$config = $this->container->get(ConfigInterface::class);
|
||||
$consumerConfig = $config->get('amqp.default_consumer');
|
||||
|
||||
// 创建连接
|
||||
$connection = new AMQPStreamConnection(
|
||||
$consumerConfig['host'],
|
||||
$consumerConfig['port'],
|
||||
$consumerConfig['user'],
|
||||
$consumerConfig['password'],
|
||||
$consumerConfig['vhost'],
|
||||
false,
|
||||
'AMQPLAIN',
|
||||
null,
|
||||
'en_US',
|
||||
$consumerConfig['params']['connection_timeout'] ?? 3.0,
|
||||
$consumerConfig['params']['read_write_timeout'] ?? 3.0,
|
||||
null,
|
||||
$consumerConfig['params']['keepalive'] ?? false,
|
||||
$consumerConfig['params']['heartbeat'] ?? 0
|
||||
);
|
||||
|
||||
$channel = $connection->channel();
|
||||
$service = $this->container->get(MqStatusService::class);
|
||||
|
||||
// 获取 --queue 参数
|
||||
$filterQueue = $this->input->getOption('queue');
|
||||
|
||||
// 确定要显示的队列组
|
||||
$queueTypes = $filterQueue
|
||||
? [$filterQueue]
|
||||
: self::QUEUE_TYPES;
|
||||
$filter_queue = $this->input->getOption('queue');
|
||||
|
||||
// 验证队列类型
|
||||
if ($filterQueue && !in_array($filterQueue, self::QUEUE_TYPES)) {
|
||||
$this->error("Invalid queue type: {$filterQueue}");
|
||||
$this->line("Valid types: " . implode(', ', self::QUEUE_TYPES));
|
||||
$channel->close();
|
||||
$connection->close();
|
||||
if ($filter_queue && !in_array($filter_queue, $service->getValidQueueTypes())) {
|
||||
$this->error("Invalid queue type: {$filter_queue}");
|
||||
$this->line("Valid types: " . implode(', ', $service->getValidQueueTypes()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
$totalMessages = 0;
|
||||
$totalConsumers = 0;
|
||||
$allQueueNames = [];
|
||||
|
||||
// 收集主业务队列和死信队列的数据
|
||||
$businessQueuesData = [];
|
||||
$deadLetterQueuesData = [];
|
||||
|
||||
foreach ($queueTypes as $type) {
|
||||
$groupData = $this->fetchQueueGroupData($channel, $type);
|
||||
|
||||
foreach ($groupData as $queueInfo) {
|
||||
// 区分主队列和重试队列(死信队列)
|
||||
if (str_ends_with($queueInfo['queue'], '.retry.queue')) {
|
||||
$deadLetterQueuesData[] = $queueInfo;
|
||||
} else {
|
||||
$businessQueuesData[] = $queueInfo;
|
||||
}
|
||||
|
||||
if (is_numeric($queueInfo['messages'])) {
|
||||
$totalMessages += $queueInfo['messages'];
|
||||
}
|
||||
if (is_numeric($queueInfo['consumers'])) {
|
||||
$totalConsumers += $queueInfo['consumers'];
|
||||
}
|
||||
$allQueueNames[] = $queueInfo['queue'];
|
||||
}
|
||||
}
|
||||
// 通过 Service 获取队列状态数据
|
||||
$status = $service->getStatus($filter_queue);
|
||||
|
||||
// 显示主业务队列表格
|
||||
$this->displayBusinessQueues($businessQueuesData, $filterQueue);
|
||||
$this->displayBusinessQueues($status['business_queues'], $filter_queue);
|
||||
|
||||
// 显示死信队列(重试队列)
|
||||
if (!empty($deadLetterQueuesData)) {
|
||||
if (!empty($status['retry_queues'])) {
|
||||
$this->line('');
|
||||
$this->displayDeadLetterQueues($deadLetterQueuesData, $filterQueue);
|
||||
$this->displayDeadLetterQueues($status['retry_queues'], $filter_queue);
|
||||
}
|
||||
|
||||
// 显示共享的错误队列
|
||||
if (!$filterQueue) {
|
||||
if (!empty($status['error_queue'])) {
|
||||
$this->line('');
|
||||
$errorQueueData = $this->fetchQueueData($channel, 'errors.queue');
|
||||
$this->displaySharedQueues([$errorQueueData]);
|
||||
|
||||
if (is_numeric($errorQueueData['messages'])) {
|
||||
$totalMessages += $errorQueueData['messages'];
|
||||
}
|
||||
if (is_numeric($errorQueueData['consumers'])) {
|
||||
$totalConsumers += $errorQueueData['consumers'];
|
||||
}
|
||||
$allQueueNames[] = $errorQueueData['queue'];
|
||||
$this->displaySharedQueues([$status['error_queue']]);
|
||||
}
|
||||
|
||||
// 关闭连接
|
||||
$channel->close();
|
||||
$connection->close();
|
||||
|
||||
// 显示汇总信息
|
||||
$this->line('');
|
||||
$this->info("=== Summary ===");
|
||||
$this->line("Total messages: <fg=yellow>{$totalMessages}</>");
|
||||
$this->line("Total active consumers: <fg=cyan>{$totalConsumers}</>");
|
||||
$this->line("Total messages: <fg=yellow>{$status['summary']['total_messages']}</>");
|
||||
$this->line("Total active consumers: <fg=cyan>{$status['summary']['total_consumers']}</>");
|
||||
$this->line('');
|
||||
|
||||
// 列出所有监控的队列名称
|
||||
$all_queue_names = [];
|
||||
foreach ($status['business_queues'] as $q) {
|
||||
$all_queue_names[] = $q['queue'];
|
||||
}
|
||||
foreach ($status['retry_queues'] as $q) {
|
||||
$all_queue_names[] = $q['queue'];
|
||||
}
|
||||
if (!empty($status['error_queue'])) {
|
||||
$all_queue_names[] = $status['error_queue']['queue'];
|
||||
}
|
||||
|
||||
$this->line('Queues monitored:');
|
||||
foreach ($allQueueNames as $queueName) {
|
||||
$this->line(" - {$queueName}");
|
||||
foreach ($all_queue_names as $queue_name) {
|
||||
$this->line(" - {$queue_name}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Failed to fetch queue status: ' . $e->getMessage());
|
||||
$this->line('Trace: ' . $e->getTraceAsString(), 'comment');
|
||||
return 1;
|
||||
@@ -176,96 +116,41 @@ class AppMqStatus extends HyperfCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列组数据(主队列 + 重试队列)
|
||||
* 将 Service 返回的 status 枚举值转为 CLI ANSI 格式
|
||||
*/
|
||||
private function fetchQueueGroupData($channel, string $type): array
|
||||
private function formatStatus(string $status): string
|
||||
{
|
||||
$queues = [
|
||||
"{$type}.queue", // 主业务队列
|
||||
"{$type}.retry.queue", // 重试队列
|
||||
];
|
||||
|
||||
$groupData = [];
|
||||
foreach ($queues as $queueName) {
|
||||
$groupData[] = $this->fetchQueueData($channel, $queueName);
|
||||
}
|
||||
|
||||
return $groupData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个队列的数据
|
||||
*/
|
||||
private function fetchQueueData($channel, string $queueName): array
|
||||
{
|
||||
try {
|
||||
// 使用 passive=true 来获取队列信息而不创建队列
|
||||
[$queue, $messageCount, $consumerCount] = $channel->queue_declare(
|
||||
$queueName,
|
||||
true, // passive
|
||||
true, // durable
|
||||
false, // exclusive
|
||||
false // auto_delete
|
||||
);
|
||||
|
||||
return [
|
||||
'queue' => $queueName,
|
||||
'messages' => $messageCount,
|
||||
'consumers' => $consumerCount,
|
||||
'status' => $this->getQueueStatus($messageCount, $consumerCount),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'queue' => $queueName,
|
||||
'messages' => 'N/A',
|
||||
'consumers' => 'N/A',
|
||||
'status' => '<fg=red>Error: ' . $e->getMessage() . '</>',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列状态描述
|
||||
*/
|
||||
private function getQueueStatus(int $messageCount, int $consumerCount): string
|
||||
{
|
||||
if ($messageCount > 100) {
|
||||
return '<fg=red>⚠ High Load</>';
|
||||
} elseif ($messageCount > 10) {
|
||||
return '<fg=yellow>⚡ Processing</>';
|
||||
} elseif ($messageCount > 0) {
|
||||
return '<fg=cyan>✓ Active</>';
|
||||
} else {
|
||||
return '<fg=green>✓ Empty</>';
|
||||
}
|
||||
return match ($status) {
|
||||
'high_load' => '<fg=red>⚠ High Load</>',
|
||||
'processing' => '<fg=yellow>⚡ Processing</>',
|
||||
'active' => '<fg=cyan>✓ Active</>',
|
||||
'empty' => '<fg=green>✓ Empty</>',
|
||||
'error' => '<fg=red>✗ Error</>',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示业务队列(合并所有队列组)- 转置显示
|
||||
*/
|
||||
private function displayBusinessQueues(array $allGroupsData, ?string $filterQueue): void
|
||||
private function displayBusinessQueues(array $all_groups_data, ?string $filter_queue): void
|
||||
{
|
||||
// 构建标题
|
||||
$title = $filterQueue
|
||||
? "Business Queues (Filtered: {$filterQueue})"
|
||||
$title = $filter_queue
|
||||
? "Business Queues (Filtered: {$filter_queue})"
|
||||
: "Business Queues";
|
||||
|
||||
$this->line("<fg=blue>=== {$title} ===</>");
|
||||
|
||||
// 构建表头 - 使用简化的队列名称(去掉 .queue)
|
||||
$headers = ['Metric'];
|
||||
foreach ($allGroupsData as $queueInfo) {
|
||||
$queueName = $queueInfo['queue'];
|
||||
// 去掉 .queue 后缀
|
||||
$simpleName = str_replace('.queue', '', $queueName);
|
||||
$headers[] = $simpleName;
|
||||
foreach ($all_groups_data as $queue_info) {
|
||||
$simple_name = str_replace('.queue', '', $queue_info['queue']);
|
||||
$headers[] = $simple_name;
|
||||
}
|
||||
|
||||
// 构建行数据 - 转置显示
|
||||
$rows = [
|
||||
$this->buildMetricRow('Messages', $allGroupsData, 'messages'),
|
||||
$this->buildMetricRow('Consumers', $allGroupsData, 'consumers'),
|
||||
$this->buildMetricRow('Status', $allGroupsData, 'status'),
|
||||
$this->buildMetricRow('Messages', $all_groups_data, 'messages'),
|
||||
$this->buildMetricRow('Consumers', $all_groups_data, 'consumers'),
|
||||
$this->buildMetricRow('Status', $all_groups_data, 'status'),
|
||||
];
|
||||
|
||||
$this->table($headers, $rows);
|
||||
@@ -275,34 +160,29 @@ class AppMqStatus extends HyperfCommand
|
||||
/**
|
||||
* 显示死信队列(重试队列)- 转置显示
|
||||
*/
|
||||
private function displayDeadLetterQueues(array $queuesData, ?string $filterQueue): void
|
||||
private function displayDeadLetterQueues(array $queues_data, ?string $filter_queue): void
|
||||
{
|
||||
$title = $filterQueue
|
||||
? "Dead Letter Queues (Filtered: {$filterQueue})"
|
||||
$title = $filter_queue
|
||||
? "Dead Letter Queues (Filtered: {$filter_queue})"
|
||||
: "Dead Letter Queues (Retry Queues)";
|
||||
|
||||
$this->line("<fg=magenta>=== {$title} ===</>");
|
||||
|
||||
// 构建表头
|
||||
$headers = ['Metric'];
|
||||
foreach ($queuesData as $queueInfo) {
|
||||
$queueName = $queueInfo['queue'];
|
||||
// 去掉 .queue 后缀,保留 retry 标识
|
||||
$simpleName = str_replace('.queue', '', $queueName);
|
||||
$headers[] = $simpleName;
|
||||
foreach ($queues_data as $queue_info) {
|
||||
$simple_name = str_replace('.queue', '', $queue_info['queue']);
|
||||
$headers[] = $simple_name;
|
||||
}
|
||||
|
||||
// 构建行数据
|
||||
$rows = [
|
||||
$this->buildMetricRow('Messages', $queuesData, 'messages'),
|
||||
$this->buildMetricRow('Consumers', $queuesData, 'consumers'),
|
||||
$this->buildMetricRow('Status', $queuesData, 'status'),
|
||||
$this->buildMetricRow('Messages', $queues_data, 'messages'),
|
||||
$this->buildMetricRow('Consumers', $queues_data, 'consumers'),
|
||||
$this->buildMetricRow('Status', $queues_data, 'status'),
|
||||
];
|
||||
|
||||
$this->table($headers, $rows);
|
||||
|
||||
// 添加说明
|
||||
if (!empty($queuesData) && $this->hasMessages($queuesData)) {
|
||||
if (!empty($queues_data) && $this->hasMessages($queues_data)) {
|
||||
$this->line('<fg=yellow>ℹ These queues receive messages from DLX when main queue processing fails</>');
|
||||
}
|
||||
}
|
||||
@@ -310,30 +190,25 @@ class AppMqStatus extends HyperfCommand
|
||||
/**
|
||||
* 显示共享队列(errors.queue)- 转置显示
|
||||
*/
|
||||
private function displaySharedQueues(array $queuesData): void
|
||||
private function displaySharedQueues(array $queues_data): void
|
||||
{
|
||||
$this->line("<fg=blue>=== Shared Queues ===</>");
|
||||
|
||||
// 构建表头
|
||||
$headers = ['Metric'];
|
||||
foreach ($queuesData as $queueInfo) {
|
||||
$queueName = $queueInfo['queue'];
|
||||
// 去掉 .queue 后缀
|
||||
$simpleName = str_replace('.queue', '', $queueName);
|
||||
$headers[] = $simpleName;
|
||||
foreach ($queues_data as $queue_info) {
|
||||
$simple_name = str_replace('.queue', '', $queue_info['queue']);
|
||||
$headers[] = $simple_name;
|
||||
}
|
||||
|
||||
// 构建行数据
|
||||
$rows = [
|
||||
$this->buildMetricRow('Messages', $queuesData, 'messages'),
|
||||
$this->buildMetricRow('Consumers', $queuesData, 'consumers'),
|
||||
$this->buildMetricRow('Status', $queuesData, 'status'),
|
||||
$this->buildMetricRow('Messages', $queues_data, 'messages'),
|
||||
$this->buildMetricRow('Consumers', $queues_data, 'consumers'),
|
||||
$this->buildMetricRow('Status', $queues_data, 'status'),
|
||||
];
|
||||
|
||||
$this->table($headers, $rows);
|
||||
|
||||
// 添加说明
|
||||
if (!empty($queuesData) && $this->hasMessages($queuesData)) {
|
||||
if (!empty($queues_data) && $this->hasMessages($queues_data)) {
|
||||
$this->line('<fg=yellow>ℹ Error queue contains messages that exceeded max retry count</>');
|
||||
}
|
||||
}
|
||||
@@ -341,10 +216,10 @@ class AppMqStatus extends HyperfCommand
|
||||
/**
|
||||
* 检查队列中是否有消息
|
||||
*/
|
||||
private function hasMessages(array $queuesData): bool
|
||||
private function hasMessages(array $queues_data): bool
|
||||
{
|
||||
foreach ($queuesData as $queueInfo) {
|
||||
if (is_numeric($queueInfo['messages']) && $queueInfo['messages'] > 0) {
|
||||
foreach ($queues_data as $queue_info) {
|
||||
if (is_numeric($queue_info['messages']) && $queue_info['messages'] > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -354,15 +229,16 @@ class AppMqStatus extends HyperfCommand
|
||||
/**
|
||||
* 构建指标行数据
|
||||
*/
|
||||
private function buildMetricRow(string $metricName, array $queuesData, string $field): array
|
||||
private function buildMetricRow(string $metric_name, array $queues_data, string $field): array
|
||||
{
|
||||
$row = [$metricName];
|
||||
$row = [$metric_name];
|
||||
|
||||
foreach ($queuesData as $queueInfo) {
|
||||
$value = $queueInfo[$field];
|
||||
foreach ($queues_data as $queue_info) {
|
||||
$value = $queue_info[$field];
|
||||
|
||||
// 根据字段类型格式化显示
|
||||
if ($field === 'messages' || $field === 'consumers') {
|
||||
if ($field === 'status') {
|
||||
$row[] = $this->formatStatus((string) $value);
|
||||
} elseif ($field === 'messages' || $field === 'consumers') {
|
||||
$row[] = $this->formatNumber($value);
|
||||
} else {
|
||||
$row[] = $value;
|
||||
@@ -375,7 +251,7 @@ class AppMqStatus extends HyperfCommand
|
||||
/**
|
||||
* 格式化数字显示
|
||||
*/
|
||||
private function formatNumber($value): string
|
||||
private function formatNumber(mixed $value): string
|
||||
{
|
||||
if (!is_numeric($value)) {
|
||||
return (string) $value;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api\V1;
|
||||
|
||||
use App\Controller\AbstractController;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
use App\Middleware\PermissionMiddleware;
|
||||
use App\Service\MqStatusService;
|
||||
use Hyperf\HttpServer\Annotation\Controller;
|
||||
use Hyperf\HttpServer\Annotation\Middleware;
|
||||
use Hyperf\HttpServer\Annotation\RequestMapping;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* 消息队列状态监控接口
|
||||
*
|
||||
* 仅 admin 角色可访问,返回 RabbitMQ 队列运行状态
|
||||
*/
|
||||
#[OA\Tag(name: 'MQ Status', description: '消息队列状态监控')]
|
||||
#[Controller(prefix: "/api/v1/mq")]
|
||||
#[Middleware(AuthMiddleware::class)]
|
||||
#[Middleware(PermissionMiddleware::class)]
|
||||
class MqStatusController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly MqStatusService $mqStatusService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取消息队列状态
|
||||
*/
|
||||
#[OA\Get(
|
||||
path: '/api/v1/mq/status',
|
||||
summary: '获取消息队列状态',
|
||||
description: '查询 RabbitMQ 各队列的消息数、消费者数和运行状态。支持按队列类型筛选。仅 admin 角色可访问。',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['MQ Status'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'queue',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: '队列类型筛选(orders/products/refunds/inventory)',
|
||||
schema: new OA\Schema(type: 'string', enum: ['orders', 'products', 'refunds', 'inventory'])
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: '获取成功',
|
||||
content: new OA\JsonContent(properties: [
|
||||
new OA\Property(property: 'code', type: 'integer', example: 0),
|
||||
new OA\Property(property: 'message', type: 'string', example: '获取成功'),
|
||||
new OA\Property(property: 'data', ref: '#/components/schemas/MqQueueStatus'),
|
||||
])
|
||||
),
|
||||
new OA\Response(response: 400, description: '无效的队列类型参数', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
|
||||
new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
|
||||
new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
|
||||
new OA\Response(response: 500, description: 'RabbitMQ 连接异常', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
|
||||
]
|
||||
)]
|
||||
#[RequestMapping(path: "status", methods: "GET")]
|
||||
public function status(): ResponseInterface|array
|
||||
{
|
||||
$queue_type = $this->request->input('queue');
|
||||
|
||||
// 校验队列类型参数
|
||||
if ($queue_type !== null && !in_array($queue_type, $this->mqStatusService->getValidQueueTypes(), true)) {
|
||||
return $this->response->json([
|
||||
'code' => 400,
|
||||
'message' => "无效的队列类型: {$queue_type},可选值: " . implode(', ', $this->mqStatusService->getValidQueueTypes()),
|
||||
'data' => null,
|
||||
])->withStatus(400);
|
||||
}
|
||||
|
||||
try {
|
||||
$data = $this->mqStatusService->getStatus($queue_type);
|
||||
|
||||
return [
|
||||
'code' => 0,
|
||||
'message' => '获取成功',
|
||||
'data' => $data,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return $this->response->json([
|
||||
'code' => 500,
|
||||
'message' => 'RabbitMQ 连接异常: ' . $e->getMessage(),
|
||||
'data' => null,
|
||||
])->withStatus(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,32 @@ use OpenApi\Attributes as OA;
|
||||
new OA\Property(property: 'data', type: 'object', nullable: true),
|
||||
]
|
||||
)]
|
||||
#[OA\Schema(
|
||||
schema: 'MqQueueInfo',
|
||||
type: 'object',
|
||||
description: '单个队列的状态信息',
|
||||
properties: [
|
||||
new OA\Property(property: 'queue', type: 'string', example: 'orders.queue', description: '队列名称'),
|
||||
new OA\Property(property: 'messages', description: '消息数量(异常时为 N/A)', oneOf: [new OA\Schema(type: 'integer'), new OA\Schema(type: 'string')], example: 5),
|
||||
new OA\Property(property: 'consumers', description: '消费者数量(异常时为 N/A)', oneOf: [new OA\Schema(type: 'integer'), new OA\Schema(type: 'string')], example: 1),
|
||||
new OA\Property(property: 'status', type: 'string', enum: ['high_load', 'processing', 'active', 'empty', 'error'], example: 'active', description: '队列状态'),
|
||||
]
|
||||
)]
|
||||
#[OA\Schema(
|
||||
schema: 'MqQueueStatus',
|
||||
type: 'object',
|
||||
description: '消息队列全量状态',
|
||||
properties: [
|
||||
new OA\Property(property: 'business_queues', type: 'array', items: new OA\Items(ref: '#/components/schemas/MqQueueInfo'), description: '业务队列列表'),
|
||||
new OA\Property(property: 'retry_queues', type: 'array', items: new OA\Items(ref: '#/components/schemas/MqQueueInfo'), description: '重试队列列表'),
|
||||
new OA\Property(property: 'error_queue', ref: '#/components/schemas/MqQueueInfo', description: '错误队列(筛选时为空数组)'),
|
||||
new OA\Property(property: 'summary', properties: [
|
||||
new OA\Property(property: 'total_messages', type: 'integer', example: 7, description: '消息总数'),
|
||||
new OA\Property(property: 'total_consumers', type: 'integer', example: 1, description: '消费者总数'),
|
||||
], type: 'object', description: '汇总统计'),
|
||||
new OA\Property(property: 'fetched_at', type: 'string', format: 'date-time', example: '2026-03-13 12:00:00', description: '数据获取时间'),
|
||||
]
|
||||
)]
|
||||
class OpenApiSpec
|
||||
{
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Hyperf\Contract\ConfigInterface;
|
||||
use PhpAmqpLib\Connection\AMQPStreamConnection;
|
||||
|
||||
/**
|
||||
* 消息队列状态服务
|
||||
*
|
||||
* 提供 RabbitMQ 队列状态查询能力,供 CLI 命令和 API 接口共用
|
||||
*/
|
||||
class MqStatusService
|
||||
{
|
||||
/**
|
||||
* 业务队列类型
|
||||
*/
|
||||
public const QUEUE_TYPES = ['orders', 'products', 'refunds', 'inventory'];
|
||||
|
||||
public function __construct(
|
||||
protected readonly ConfigInterface $config,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取合法的队列类型列表
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getValidQueueTypes(): array
|
||||
{
|
||||
return self::QUEUE_TYPES;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列状态
|
||||
*
|
||||
* @param string|null $queue_type 筛选队列类型(orders/products/refunds/inventory)
|
||||
* @return array{business_queues: array, retry_queues: array, error_queue: array, summary: array, fetched_at: string}
|
||||
*/
|
||||
public function getStatus(?string $queue_type = null): array
|
||||
{
|
||||
$consumer_config = $this->config->get('amqp.default_consumer');
|
||||
|
||||
$connection = new AMQPStreamConnection(
|
||||
$consumer_config['host'],
|
||||
$consumer_config['port'],
|
||||
$consumer_config['user'],
|
||||
$consumer_config['password'],
|
||||
$consumer_config['vhost'],
|
||||
false,
|
||||
'AMQPLAIN',
|
||||
null,
|
||||
'en_US',
|
||||
$consumer_config['params']['connection_timeout'] ?? 3.0,
|
||||
$consumer_config['params']['read_write_timeout'] ?? 3.0,
|
||||
null,
|
||||
$consumer_config['params']['keepalive'] ?? false,
|
||||
$consumer_config['params']['heartbeat'] ?? 0
|
||||
);
|
||||
|
||||
$channel = $connection->channel();
|
||||
|
||||
try {
|
||||
$queue_types = $queue_type ? [$queue_type] : self::QUEUE_TYPES;
|
||||
|
||||
$business_queues = [];
|
||||
$retry_queues = [];
|
||||
$total_messages = 0;
|
||||
$total_consumers = 0;
|
||||
|
||||
foreach ($queue_types as $type) {
|
||||
$group_data = $this->fetchQueueGroupData($channel, $type);
|
||||
|
||||
foreach ($group_data as $queue_info) {
|
||||
if (str_ends_with($queue_info['queue'], '.retry.queue')) {
|
||||
$retry_queues[] = $queue_info;
|
||||
} else {
|
||||
$business_queues[] = $queue_info;
|
||||
}
|
||||
|
||||
if (is_numeric($queue_info['messages'])) {
|
||||
$total_messages += $queue_info['messages'];
|
||||
}
|
||||
if (is_numeric($queue_info['consumers'])) {
|
||||
$total_consumers += $queue_info['consumers'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 错误队列仅在未筛选时返回
|
||||
$error_queue = [];
|
||||
if ($queue_type === null) {
|
||||
$error_queue = $this->fetchQueueData($channel, 'errors.queue');
|
||||
|
||||
if (is_numeric($error_queue['messages'])) {
|
||||
$total_messages += $error_queue['messages'];
|
||||
}
|
||||
if (is_numeric($error_queue['consumers'])) {
|
||||
$total_consumers += $error_queue['consumers'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'business_queues' => $business_queues,
|
||||
'retry_queues' => $retry_queues,
|
||||
'error_queue' => $error_queue,
|
||||
'summary' => [
|
||||
'total_messages' => $total_messages,
|
||||
'total_consumers' => $total_consumers,
|
||||
],
|
||||
'fetched_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
} finally {
|
||||
$channel->close();
|
||||
$connection->close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列组数据(主队列 + 重试队列)
|
||||
*
|
||||
* @return array<array{queue: string, messages: int|string, consumers: int|string, status: string}>
|
||||
*/
|
||||
private function fetchQueueGroupData(mixed $channel, string $type): array
|
||||
{
|
||||
$queues = [
|
||||
"{$type}.queue",
|
||||
"{$type}.retry.queue",
|
||||
];
|
||||
|
||||
$group_data = [];
|
||||
foreach ($queues as $queue_name) {
|
||||
$group_data[] = $this->fetchQueueData($channel, $queue_name);
|
||||
}
|
||||
|
||||
return $group_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个队列的数据
|
||||
*
|
||||
* @return array{queue: string, messages: int|string, consumers: int|string, status: string}
|
||||
*/
|
||||
private function fetchQueueData(mixed $channel, string $queue_name): array
|
||||
{
|
||||
try {
|
||||
// 使用 passive=true 获取队列信息而不创建队列
|
||||
[, $message_count, $consumer_count] = $channel->queue_declare(
|
||||
$queue_name,
|
||||
true, // passive
|
||||
true, // durable
|
||||
false, // exclusive
|
||||
false // auto_delete
|
||||
);
|
||||
|
||||
return [
|
||||
'queue' => $queue_name,
|
||||
'messages' => $message_count,
|
||||
'consumers' => $consumer_count,
|
||||
'status' => $this->getQueueStatus($message_count),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'queue' => $queue_name,
|
||||
'messages' => 'N/A',
|
||||
'consumers' => 'N/A',
|
||||
'status' => 'error',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息数量判断队列状态
|
||||
*
|
||||
* @return string 状态枚举:high_load/processing/active/empty
|
||||
*/
|
||||
public function getQueueStatus(int $message_count): string
|
||||
{
|
||||
if ($message_count > 100) {
|
||||
return 'high_load';
|
||||
}
|
||||
if ($message_count > 10) {
|
||||
return 'processing';
|
||||
}
|
||||
if ($message_count > 0) {
|
||||
return 'active';
|
||||
}
|
||||
return 'empty';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace HyperfTest\Cases\Integration\System;
|
||||
|
||||
use HyperfTest\TestCase;
|
||||
use HyperfTest\Traits\AuthenticatedTestTrait;
|
||||
|
||||
/**
|
||||
* MqStatusController 集成测试
|
||||
*
|
||||
* 覆盖接口结构、队列参数筛选、无效参数 400、未认证 401
|
||||
*
|
||||
* @internal
|
||||
* @coversNothing
|
||||
*/
|
||||
class MqStatusControllerTest extends TestCase
|
||||
{
|
||||
use AuthenticatedTestTrait;
|
||||
|
||||
// ========== 正常返回结构 ==========
|
||||
|
||||
public function test_mq_status_returns_expected_structure(): void
|
||||
{
|
||||
$response = $this->get('/api/v1/mq/status', [], $this->authHeaders());
|
||||
|
||||
// RabbitMQ 可能不可用,接受 200 或 500
|
||||
$status = $response->getStatusCode();
|
||||
if ($status === 500) {
|
||||
// 连接不可用时返回 500,仍验证响应格式
|
||||
$response->assertJsonStructure(['code', 'message', 'data']);
|
||||
$this->assertSame(500, $response->json('code'));
|
||||
return;
|
||||
}
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('code', 0);
|
||||
$response->assertJsonPath('message', '获取成功');
|
||||
$response->assertJsonStructure([
|
||||
'code',
|
||||
'message',
|
||||
'data' => [
|
||||
'business_queues',
|
||||
'retry_queues',
|
||||
'error_queue',
|
||||
'summary' => [
|
||||
'total_messages',
|
||||
'total_consumers',
|
||||
],
|
||||
'fetched_at',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ========== 队列类型筛选 ==========
|
||||
|
||||
public function test_mq_status_with_valid_queue_filter(): void
|
||||
{
|
||||
$response = $this->get('/api/v1/mq/status', ['queue' => 'orders'], $this->authHeaders());
|
||||
|
||||
$status = $response->getStatusCode();
|
||||
if ($status === 500) {
|
||||
// RabbitMQ 不可用,跳过具体数据验证
|
||||
return;
|
||||
}
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('code', 0);
|
||||
|
||||
$data = $response->json('data');
|
||||
|
||||
// 筛选时 business_queues 仅包含 orders.queue
|
||||
foreach ($data['business_queues'] as $queue) {
|
||||
$this->assertStringContainsString('orders', $queue['queue']);
|
||||
}
|
||||
|
||||
// 筛选时 error_queue 为空数组(仅全量查询时返回)
|
||||
$this->assertEmpty($data['error_queue']);
|
||||
}
|
||||
|
||||
// ========== 无效参数 ==========
|
||||
|
||||
public function test_mq_status_with_invalid_queue_returns_400(): void
|
||||
{
|
||||
$response = $this->get('/api/v1/mq/status', ['queue' => 'invalid_type'], $this->authHeaders());
|
||||
|
||||
$response->assertStatus(400);
|
||||
$this->assertSame(400, $response->json('code'));
|
||||
$this->assertStringContainsString('无效的队列类型', $response->json('message'));
|
||||
}
|
||||
|
||||
// ========== 认证检查 ==========
|
||||
|
||||
public function test_mq_status_without_token_returns_401(): void
|
||||
{
|
||||
$response = $this->get('/api/v1/mq/status');
|
||||
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace HyperfTest\Cases\Unit\Service;
|
||||
|
||||
use App\Service\MqStatusService;
|
||||
use Hyperf\Contract\ConfigInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* MqStatusService 单元测试
|
||||
*
|
||||
* 覆盖队列类型常量、状态判定逻辑
|
||||
*
|
||||
* @internal
|
||||
* @coversNothing
|
||||
*/
|
||||
class MqStatusServiceTest extends TestCase
|
||||
{
|
||||
private MqStatusService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$config = $this->createMock(ConfigInterface::class);
|
||||
$this->service = new MqStatusService($config);
|
||||
}
|
||||
|
||||
// ========== getValidQueueTypes ==========
|
||||
|
||||
public function test_get_valid_queue_types_returns_correct_list(): void
|
||||
{
|
||||
$types = $this->service->getValidQueueTypes();
|
||||
|
||||
$this->assertIsArray($types);
|
||||
$this->assertContains('orders', $types);
|
||||
$this->assertContains('products', $types);
|
||||
$this->assertContains('refunds', $types);
|
||||
$this->assertContains('inventory', $types);
|
||||
$this->assertCount(4, $types);
|
||||
}
|
||||
|
||||
// ========== getQueueStatus 状态判定 ==========
|
||||
|
||||
public function test_status_high_load_when_messages_over_100(): void
|
||||
{
|
||||
$this->assertSame('high_load', $this->service->getQueueStatus(101));
|
||||
$this->assertSame('high_load', $this->service->getQueueStatus(500));
|
||||
$this->assertSame('high_load', $this->service->getQueueStatus(10000));
|
||||
}
|
||||
|
||||
public function test_status_processing_when_messages_over_10(): void
|
||||
{
|
||||
$this->assertSame('processing', $this->service->getQueueStatus(11));
|
||||
$this->assertSame('processing', $this->service->getQueueStatus(50));
|
||||
$this->assertSame('processing', $this->service->getQueueStatus(100));
|
||||
}
|
||||
|
||||
public function test_status_active_when_messages_over_0(): void
|
||||
{
|
||||
$this->assertSame('active', $this->service->getQueueStatus(1));
|
||||
$this->assertSame('active', $this->service->getQueueStatus(5));
|
||||
$this->assertSame('active', $this->service->getQueueStatus(10));
|
||||
}
|
||||
|
||||
public function test_status_empty_when_no_messages(): void
|
||||
{
|
||||
$this->assertSame('empty', $this->service->getQueueStatus(0));
|
||||
}
|
||||
|
||||
// ========== 边界值测试 ==========
|
||||
|
||||
public function test_status_boundary_at_exactly_100(): void
|
||||
{
|
||||
// 100 条消息 → processing(> 10 且 <= 100)
|
||||
$this->assertSame('processing', $this->service->getQueueStatus(100));
|
||||
}
|
||||
|
||||
public function test_status_boundary_at_exactly_10(): void
|
||||
{
|
||||
// 10 条消息 → active(> 0 且 <= 10)
|
||||
$this->assertSame('active', $this->service->getQueueStatus(10));
|
||||
}
|
||||
|
||||
public function test_status_boundary_at_exactly_1(): void
|
||||
{
|
||||
$this->assertSame('active', $this->service->getQueueStatus(1));
|
||||
}
|
||||
|
||||
// ========== QUEUE_TYPES 常量 ==========
|
||||
|
||||
public function test_queue_types_constant_matches_valid_types(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
MqStatusService::QUEUE_TYPES,
|
||||
$this->service->getValidQueueTypes()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user