This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 245699f92 feat(studio): support cloud direct message consumption 
(#4455)
245699f92 is described below

commit 245699f92dcc3df40c67b34a39863d88b13e3b3d
Author: aias00 <[email protected]>
AuthorDate: Sat Sep 26 03:43:35 2026 -0700

    feat(studio): support cloud direct message consumption (#4455)
    
    fix(studio): address cloud direct consume review
    
    Signed-off-by: liuhy <[email protected]>
---
 docs/api-spec.md                                   |  10 +-
 .../studio/instance/message/MessageService.java    |  20 +++-
 .../studio/provider/InstanceCapability.java        |   1 +
 .../provider/alibaba/AliyunInstanceProvider.java   |  46 ++++++++
 .../provider/apache/ApacheInstanceProvider.java    |   1 +
 .../provider/tencent/TencentInstanceProvider.java  |  36 ++++++
 .../instance/message/MessageServiceTest.java       |  35 ++++++
 .../alibaba/AliyunInstanceProviderTest.java        |  86 +++++++++++++++
 .../apache/ApacheInstanceProviderTest.java         |   1 +
 .../tencent/TencentInstanceProviderTest.java       |  51 +++++++++
 web/src/api/instance.ts                            |   1 +
 web/src/i18n/translations.ts                       |  16 +++
 web/src/mock/instances.ts                          |  31 +++++-
 .../pages/instance/__tests__/MessagePage.test.tsx  |  10 ++
 .../__tests__/MessagePageAsyncState.test.tsx       | 121 +++++++++++++++++++++
 web/src/pages/instance/message.tsx                 |  73 +++++++++++--
 web/src/services/instanceService.test.ts           |  35 +++++-
 web/src/services/instanceService.ts                |  21 +---
 18 files changed, 558 insertions(+), 37 deletions(-)

diff --git a/docs/api-spec.md b/docs/api-spec.md
index 0f8dbc2f9..4763e67bd 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -1503,11 +1503,11 @@ POST /api/messages/direct-consume
 
 | 字段 | 类型 | 说明 |
 |------|------|------|
-| `consumeResult` | `string` | Broker 返回的直接消费结果 |
-| `remark` | `string` | Broker 返回的说明 |
-| `spentTimeMillis` | `number` | Broker 执行耗时(毫秒) |
-| `order` | `boolean` | 是否为顺序消费 |
-| `autoCommit` | `boolean` | 客户端是否启用自动提交 |
+| `consumeResult` | `string` | 直消费结果;Apache 路径为 Broker 的 
`ConsumeMessageDirectlyResult` 名(`CR_SUCCESS` 等),Aliyun 为 
`CR_SUCCESS`/`CR_FAILED`,Tencent 因 OpenAPI 不回传消费结果固定为 `REQUEST_ACCEPTED`,实际语义见 
`remark` |
+| `remark` | `string` | 结果说明;Apache 路径取自 Broker,云厂商路径取自云 OpenAPI 应答(含 
`requestId`) |
+| `spentTimeMillis` | `number` | 执行耗时(毫秒);云厂商路径为 Studio 侧测得的 OpenAPI 调用耗时 |
+| `order` | `boolean` | 是否为顺序消费(云厂商路径固定 `false`) |
+| `autoCommit` | `boolean` | 客户端是否启用自动提交(云厂商路径固定 `false`) |
 
 ---
 
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
index cb85a5528..ead1d11cc 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
@@ -23,6 +23,8 @@ import 
org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.instance.ResourceOwnershipGuard;
 import org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Resource;
 import org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Kind;
+import org.apache.rocketmq.studio.provider.InstanceCapability;
+import org.apache.rocketmq.studio.provider.InstanceProvider;
 import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.springframework.stereotype.Service;
@@ -162,10 +164,10 @@ public class MessageService {
             DirectConsumeMessageResultVO result = apache
                     ? ownershipGuard.withOwned(instance, resources, () ->
                             
providerRegistry.byInstanceId(request.getInstanceId())
-                                    .map(provider -> 
provider.consumeMessageDirectly(request))
+                                    .map(provider -> 
consumeDirectlyThrough(provider, request))
                                     .orElseGet(() -> 
messageProvider.consumeMessageDirectly(request)))
                     : providerRegistry.byInstanceId(request.getInstanceId())
-                            .map(provider -> 
provider.consumeMessageDirectly(request))
+                            .map(provider -> consumeDirectlyThrough(provider, 
request))
                             .orElseGet(() -> 
messageProvider.consumeMessageDirectly(request));
             recordDirectConsumeAudit(request, detail + ", result=" + 
result.getConsumeResult(),
                     auditResult(result.getConsumeResult()), null);
@@ -176,6 +178,20 @@ public class MessageService {
         }
     }
 
+    /**
+     * Gate the registered-provider path on the advertised capability so a 
vendor without a
+     * direct-consume API answers 501 (see {@code GlobalExceptionHandler}) 
instead of letting the
+     * call fall through to an implementation that cannot honour it.
+     */
+    private static DirectConsumeMessageResultVO 
consumeDirectlyThrough(InstanceProvider provider,
+                                                                       
DirectConsumeMessageDTO request) {
+        if 
(!provider.capabilities().contains(InstanceCapability.DIRECT_MESSAGE_CONSUME)) {
+            throw new UnsupportedOperationException(
+                    "Direct message consumption is not supported by this 
instance");
+        }
+        return provider.consumeMessageDirectly(request);
+    }
+
     /** consumeResult mirrors the broker-side CMResult enum, where only 
CR_SUCCESS means consumed. */
     private static String auditResult(String consumeResult) {
         return "CR_SUCCESS".equals(consumeResult) ? "SUCCESS" : "FAILED";
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/InstanceCapability.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/InstanceCapability.java
index a98a5433d..12fa74694 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/InstanceCapability.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/InstanceCapability.java
@@ -25,6 +25,7 @@ public enum InstanceCapability {
     MESSAGE_QUERY,
     MESSAGE_TRACE,
     MESSAGE_SEND,
+    DIRECT_MESSAGE_CONSUME,
     ACL_MANAGEMENT,
     DLQ_MANAGEMENT
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProvider.java
index da5a502ff..d1c10c0c3 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProvider.java
@@ -46,6 +46,9 @@ import 
com.aliyun.sdk.service.rocketmq20220801.models.UpdateTopicRequest;
 import com.aliyun.sdk.service.rocketmq20220801.models.VerifySendMessageRequest;
 import 
com.aliyun.sdk.service.rocketmq20220801.models.VerifySendMessageResponse;
 import 
com.aliyun.sdk.service.rocketmq20220801.models.VerifySendMessageResponseBody;
+import 
com.aliyun.sdk.service.rocketmq20220801.models.VerifyConsumeMessageRequest;
+import 
com.aliyun.sdk.service.rocketmq20220801.models.VerifyConsumeMessageResponse;
+import 
com.aliyun.sdk.service.rocketmq20220801.models.VerifyConsumeMessageResponseBody;
 import org.springframework.util.StringUtils;
 
 import org.apache.rocketmq.studio.common.domain.PageResult;
@@ -59,6 +62,8 @@ import 
org.apache.rocketmq.studio.instance.group.QueueProgressVO;
 import org.apache.rocketmq.studio.instance.group.SubscriptionEntryVO;
 import org.apache.rocketmq.studio.instance.message.MessageQueryResult;
 import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
+import org.apache.rocketmq.studio.instance.message.DirectConsumeMessageDTO;
+import 
org.apache.rocketmq.studio.instance.message.DirectConsumeMessageResultVO;
 import org.apache.rocketmq.studio.instance.message.TraceRecordVO;
 import org.apache.rocketmq.studio.instance.topic.SendMessageDTO;
 import org.apache.rocketmq.studio.instance.topic.SendMessageVO;
@@ -74,6 +79,7 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 
 /**
  * Aliyun RocketMQ 5.x implementation of the instance-scoped operations SPI, 
backed by the
@@ -109,6 +115,7 @@ public class AliyunInstanceProvider implements 
InstanceProvider {
                 InstanceCapability.MESSAGE_QUERY,
                 InstanceCapability.MESSAGE_TRACE,
                 InstanceCapability.MESSAGE_SEND,
+                InstanceCapability.DIRECT_MESSAGE_CONSUME,
                 InstanceCapability.ACL_MANAGEMENT);
     }
 
@@ -556,6 +563,35 @@ public class AliyunInstanceProvider implements 
InstanceProvider {
         return mayBeTruncated ? MessageQueryResult.truncated(records) : 
MessageQueryResult.complete(records);
     }
 
+    @Override
+    public DirectConsumeMessageResultVO 
consumeMessageDirectly(DirectConsumeMessageDTO request) {
+        Context ctx = resolve(request.getInstanceId());
+        VerifyConsumeMessageRequest verifyRequest = 
VerifyConsumeMessageRequest.builder()
+                .instanceId(ctx.cloudInstanceId())
+                .topicName(request.getTopic())
+                .messageId(request.getMsgId())
+                .consumerGroupId(request.getConsumerGroup())
+                .clientId(request.getClientId())
+                .build();
+        long startedAt = System.nanoTime();
+        VerifyConsumeMessageResponse response = 
clientFactory.call(ctx.credentialId(), ctx.regionId(),
+                client -> client.verifyConsumeMessage(verifyRequest));
+        long spentTimeMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() 
- startedAt);
+        VerifyConsumeMessageResponseBody body = response == null ? null : 
response.getBody();
+        if (body == null) {
+            throw new BusinessException(502, "Aliyun direct consume returned 
an empty response");
+        }
+        boolean success = Boolean.TRUE.equals(body.getSuccess()) && 
Boolean.TRUE.equals(body.getData());
+        String message = StringUtils.hasText(body.getMessage()) ? 
body.getMessage() : body.getDynamicMessage();
+        return DirectConsumeMessageResultVO.builder()
+                .consumeResult(success ? "CR_SUCCESS" : "CR_FAILED")
+                .remark(formatCloudRemark(message, body.getRequestId()))
+                .spentTimeMillis(spentTimeMillis)
+                .order(false)
+                .autoCommit(false)
+                .build();
+    }
+
     @Override
     public TraceRecordVO getMessageTrace(String instanceId, String msgId, 
String topic) {
         Context ctx = resolve(instanceId);
@@ -581,6 +617,16 @@ public class AliyunInstanceProvider implements 
InstanceProvider {
                 .build();
     }
 
+    private static String formatCloudRemark(String message, String requestId) {
+        if (StringUtils.hasText(message) && StringUtils.hasText(requestId)) {
+            return message + " (requestId=" + requestId + ")";
+        }
+        if (StringUtils.hasText(message)) {
+            return message;
+        }
+        return StringUtils.hasText(requestId) ? "requestId=" + requestId : 
null;
+    }
+
     private Context resolve(String instanceId) {
         if (!StringUtils.hasText(instanceId)) {
             throw new BusinessException(400, "instanceId is required");
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ApacheInstanceProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ApacheInstanceProvider.java
index f2c5572d6..02e834a57 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ApacheInstanceProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ApacheInstanceProvider.java
@@ -70,6 +70,7 @@ public class ApacheInstanceProvider implements 
InstanceProvider {
                 InstanceCapability.MESSAGE_QUERY,
                 InstanceCapability.MESSAGE_TRACE,
                 InstanceCapability.MESSAGE_SEND,
+                InstanceCapability.DIRECT_MESSAGE_CONSUME,
                 InstanceCapability.ACL_MANAGEMENT,
                 InstanceCapability.DLQ_MANAGEMENT);
     }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
index 93a6f0752..e17d26b12 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
@@ -46,6 +46,8 @@ import 
com.tencentcloudapi.trocket.v20230308.models.SendMessageRequest;
 import com.tencentcloudapi.trocket.v20230308.models.SendMessageResponse;
 import com.tencentcloudapi.trocket.v20230308.models.SubscriptionData;
 import com.tencentcloudapi.trocket.v20230308.models.TopicItem;
+import 
com.tencentcloudapi.trocket.v20230308.models.VerifyMessageConsumptionRequest;
+import 
com.tencentcloudapi.trocket.v20230308.models.VerifyMessageConsumptionResponse;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
 import org.apache.rocketmq.studio.common.domain.enums.DeliveryStatus;
@@ -62,6 +64,8 @@ import 
org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
 import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
 import org.apache.rocketmq.studio.instance.group.SubscriptionEntryVO;
 import org.apache.rocketmq.studio.instance.message.ConsumerStatusVO;
+import org.apache.rocketmq.studio.instance.message.DirectConsumeMessageDTO;
+import 
org.apache.rocketmq.studio.instance.message.DirectConsumeMessageResultVO;
 import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
 import org.apache.rocketmq.studio.instance.message.MessageQueryResult;
 import org.apache.rocketmq.studio.instance.message.TraceNodeVO;
@@ -94,6 +98,7 @@ import java.util.Locale;
 import java.util.Set;
 import java.util.Map;
 import java.util.UUID;
+import java.util.concurrent.TimeUnit;
 
 /**
  * Tencent Cloud TDMQ RocketMQ 5.x topic operations backed by Trocket 
v20230308 OpenAPI.
@@ -150,6 +155,7 @@ public class TencentInstanceProvider implements 
InstanceProvider {
                 InstanceCapability.MESSAGE_QUERY,
                 InstanceCapability.MESSAGE_TRACE,
                 InstanceCapability.MESSAGE_SEND,
+                InstanceCapability.DIRECT_MESSAGE_CONSUME,
                 InstanceCapability.ACL_MANAGEMENT);
     }
 
@@ -686,6 +692,36 @@ public class TencentInstanceProvider implements 
InstanceProvider {
         return mayBeTruncated ? MessageQueryResult.truncated(result) : 
MessageQueryResult.complete(result);
     }
 
+    @Override
+    public DirectConsumeMessageResultVO 
consumeMessageDirectly(DirectConsumeMessageDTO request) {
+        Context context = resolve(request.getInstanceId());
+        VerifyMessageConsumptionRequest verifyRequest = new 
VerifyMessageConsumptionRequest();
+        verifyRequest.setInstanceId(context.cloudInstanceId());
+        verifyRequest.setTopic(request.getTopic());
+        verifyRequest.setMsgId(request.getMsgId());
+        verifyRequest.setConsumerGroup(request.getConsumerGroup());
+        verifyRequest.setClientId(request.getClientId());
+        long startedAt = System.nanoTime();
+        VerifyMessageConsumptionResponse response = clientFactory.call(
+                context.credentialId(), context.regionId(), client -> 
client.VerifyMessageConsumption(verifyRequest));
+        long spentTimeMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() 
- startedAt);
+        if (response == null) {
+            throw new BusinessException(502, "Tencent direct consume returned 
an empty response");
+        }
+        String requestId = response.getRequestId();
+        String acknowledgement = "Tencent accepted the verification request; "
+                + "the consumption outcome is not returned";
+        return DirectConsumeMessageResultVO.builder()
+                .consumeResult("REQUEST_ACCEPTED")
+                .remark(StringUtils.hasText(requestId)
+                        ? acknowledgement + " (requestId=" + requestId + ")"
+                        : acknowledgement)
+                .spentTimeMillis(spentTimeMillis)
+                .order(false)
+                .autoCommit(false)
+                .build();
+    }
+
     @Override
     public TraceRecordVO getMessageTrace(String instanceId, String msgId, 
String topic) {
         Context context = resolve(instanceId);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
index 260123acd..b5921d1dd 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
@@ -22,17 +22,20 @@ import 
org.apache.rocketmq.studio.instance.ResourceOwnershipGuard;
 import static org.mockito.ArgumentMatchers.any;
 import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
 import org.apache.rocketmq.studio.provider.InstanceProvider;
+import org.apache.rocketmq.studio.provider.InstanceCapability;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.junit.jupiter.api.Test;
 
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.when;
 
 class MessageServiceTest {
@@ -175,6 +178,7 @@ class MessageServiceTest {
         DirectConsumeMessageResultVO expected = 
DirectConsumeMessageResultVO.builder()
                 .consumeResult("CR_SUCCESS").build();
         
when(registry.byInstanceId("instance-a")).thenReturn(Optional.of(provider));
+        
when(provider.capabilities()).thenReturn(Set.of(InstanceCapability.DIRECT_MESSAGE_CONSUME));
         when(provider.consumeMessageDirectly(request)).thenReturn(expected);
         MessageService service = new MessageService(fallback, registry, 
mock(QueryHistoryService.class), audit, ownershipGuard());
 
@@ -199,6 +203,7 @@ class MessageServiceTest {
         request.setConsumerGroup("billing");
         request.setClientId("client-a");
         
when(registry.byInstanceId("instance-a")).thenReturn(Optional.of(provider));
+        
when(provider.capabilities()).thenReturn(Set.of(InstanceCapability.DIRECT_MESSAGE_CONSUME));
         
when(provider.consumeMessageDirectly(request)).thenReturn(DirectConsumeMessageResultVO.builder()
                 .consumeResult("CR_ROLLBACK").build());
         MessageService service = new MessageService(fallback, registry, 
mock(QueryHistoryService.class), audit, ownershipGuard());
@@ -224,6 +229,7 @@ class MessageServiceTest {
         request.setConsumerGroup("billing");
         request.setClientId("client-a");
         
when(registry.byInstanceId("instance-a")).thenReturn(Optional.of(provider));
+        
when(provider.capabilities()).thenReturn(Set.of(InstanceCapability.DIRECT_MESSAGE_CONSUME));
         when(provider.consumeMessageDirectly(request)).thenThrow(new 
IllegalStateException("client offline"));
         MessageService service = new MessageService(fallback, registry, 
mock(QueryHistoryService.class), audit, ownershipGuard());
 
@@ -238,6 +244,35 @@ class MessageServiceTest {
         verifyNoInteractions(fallback);
     }
 
+    @Test
+    void 
rejectsDirectConsumptionWhenSelectedProviderDoesNotAdvertiseCapabilityTest() {
+        MessageProvider fallback = mock(MessageProvider.class);
+        InstanceProvider provider = mock(InstanceProvider.class);
+        InstanceProviderRegistry registry = 
mock(InstanceProviderRegistry.class);
+        OperationAuditService audit = mock(OperationAuditService.class);
+        DirectConsumeMessageDTO request = new DirectConsumeMessageDTO();
+        request.setInstanceId("cloud-instance");
+        request.setTopic("orders");
+        request.setMsgId("msg-1");
+        request.setConsumerGroup("billing");
+        request.setClientId("client-a");
+        
when(registry.byInstanceId("cloud-instance")).thenReturn(Optional.of(provider));
+        
when(provider.capabilities()).thenReturn(Set.of(InstanceCapability.MESSAGE_QUERY));
+        MessageService service = new MessageService(
+                fallback, registry, mock(QueryHistoryService.class), audit, 
ownershipGuard());
+
+        assertThatThrownBy(() -> service.consumeMessageDirectly(request))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessage("Direct message consumption is not supported by 
this instance");
+
+        verify(provider, never()).consumeMessageDirectly(request);
+        verifyNoInteractions(fallback);
+        
verify(audit).record(org.mockito.ArgumentMatchers.eq("DIRECT_CONSUME_MESSAGE"),
+                org.mockito.ArgumentMatchers.eq("MESSAGE"), 
org.mockito.ArgumentMatchers.eq("msg-1"),
+                org.mockito.ArgumentMatchers.eq("cloud-instance"), 
org.mockito.ArgumentMatchers.contains("billing"),
+                org.mockito.ArgumentMatchers.eq("FAILED"), 
org.mockito.ArgumentMatchers.contains("not supported"));
+    }
+
     @Test
     void rejectsOverflowingTopicQueryWindowBeforeCallingProvider() {
         MessageProvider provider = mock(MessageProvider.class);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProviderTest.java
index a3a167f92..875512d48 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProviderTest.java
@@ -40,6 +40,9 @@ import 
com.aliyun.sdk.service.rocketmq20220801.models.ResetConsumeOffsetResponse
 import com.aliyun.sdk.service.rocketmq20220801.models.VerifySendMessageRequest;
 import 
com.aliyun.sdk.service.rocketmq20220801.models.VerifySendMessageResponse;
 import 
com.aliyun.sdk.service.rocketmq20220801.models.VerifySendMessageResponseBody;
+import 
com.aliyun.sdk.service.rocketmq20220801.models.VerifyConsumeMessageRequest;
+import 
com.aliyun.sdk.service.rocketmq20220801.models.VerifyConsumeMessageResponse;
+import 
com.aliyun.sdk.service.rocketmq20220801.models.VerifyConsumeMessageResponseBody;
 import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
 import org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode;
 import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
@@ -53,6 +56,8 @@ import 
org.apache.rocketmq.studio.instance.group.QueueProgressVO;
 import org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetPreviewVO;
 import org.apache.rocketmq.studio.instance.message.MessageQueryResult;
 import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
+import org.apache.rocketmq.studio.instance.message.DirectConsumeMessageDTO;
+import 
org.apache.rocketmq.studio.instance.message.DirectConsumeMessageResultVO;
 import org.apache.rocketmq.studio.instance.message.TraceNodeVO;
 import org.apache.rocketmq.studio.instance.message.TraceRecordVO;
 import org.apache.rocketmq.studio.instance.topic.SendMessageDTO;
@@ -114,6 +119,7 @@ class AliyunInstanceProviderTest {
                 .contains(InstanceCapability.TOPIC_MANAGEMENT,
                         InstanceCapability.MESSAGE_QUERY,
                         InstanceCapability.MESSAGE_SEND,
+                        InstanceCapability.DIRECT_MESSAGE_CONSUME,
                         InstanceCapability.ACL_MANAGEMENT)
                 .doesNotContain(InstanceCapability.DLQ_MANAGEMENT);
     }
@@ -163,6 +169,76 @@ class AliyunInstanceProviderTest {
                 .hasMessageContaining("send denied");
     }
 
+    @Test
+    void consumeMessageDirectlyShouldCallAliyunVerifyApiTest() {
+        stubInstance();
+        stubCallThrough();
+        VerifyConsumeMessageResponse response = 
VerifyConsumeMessageResponse.create().toBuilder()
+                .statusCode(200)
+                .body(VerifyConsumeMessageResponseBody.builder()
+                        .success(true)
+                        .data(true)
+                        .message("accepted")
+                        .requestId("aliyun-request-1")
+                        .build())
+                .build();
+        when(asyncClient.verifyConsumeMessage(any()))
+                .thenReturn(CompletableFuture.completedFuture(response));
+
+        DirectConsumeMessageResultVO result = 
provider.consumeMessageDirectly(directConsumeRequest());
+
+        ArgumentCaptor<VerifyConsumeMessageRequest> captor =
+                ArgumentCaptor.forClass(VerifyConsumeMessageRequest.class);
+        verify(asyncClient).verifyConsumeMessage(captor.capture());
+        VerifyConsumeMessageRequest request = captor.getValue();
+        assertThat(request.getInstanceId()).isEqualTo(CLOUD_INSTANCE_ID);
+        assertThat(request.getTopicName()).isEqualTo("orders");
+        assertThat(request.getMessageId()).isEqualTo("msg-1");
+        assertThat(request.getConsumerGroupId()).isEqualTo("billing");
+        assertThat(request.getClientId()).isEqualTo("client-a");
+        assertThat(result.getConsumeResult()).isEqualTo("CR_SUCCESS");
+        assertThat(result.getRemark()).contains("accepted", 
"aliyun-request-1");
+        assertThat(result.getSpentTimeMillis()).isNotNegative();
+        assertThat(result.isOrder()).isFalse();
+        assertThat(result.isAutoCommit()).isFalse();
+    }
+
+    @Test
+    void consumeMessageDirectlyShouldPreserveAliyunBusinessFailureTest() {
+        stubInstance();
+        stubCallThrough();
+        VerifyConsumeMessageResponse response = 
VerifyConsumeMessageResponse.create().toBuilder()
+                .statusCode(200)
+                .body(VerifyConsumeMessageResponseBody.builder()
+                        .success(true)
+                        .data(false)
+                        .message("client offline")
+                        .requestId("aliyun-request-2")
+                        .build())
+                .build();
+        when(asyncClient.verifyConsumeMessage(any()))
+                .thenReturn(CompletableFuture.completedFuture(response));
+
+        DirectConsumeMessageResultVO result = 
provider.consumeMessageDirectly(directConsumeRequest());
+
+        assertThat(result.getConsumeResult()).isEqualTo("CR_FAILED");
+        assertThat(result.getRemark()).contains("client offline", 
"aliyun-request-2");
+    }
+
+    @Test
+    void consumeMessageDirectlyShouldRejectEmptyAliyunResponseTest() {
+        stubInstance();
+        stubCallThrough();
+        when(asyncClient.verifyConsumeMessage(any()))
+                .thenReturn(CompletableFuture.completedFuture(null));
+
+        assertThatThrownBy(() -> 
provider.consumeMessageDirectly(directConsumeRequest()))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Aliyun direct consume returned an empty response")
+                .extracting("code")
+                .isEqualTo(502);
+    }
+
     @Test
     void listTopicsShouldMapMessageTypeAndFilterTest() {
         stubInstance();
@@ -888,6 +964,16 @@ class AliyunInstanceProviderTest {
         });
     }
 
+    private DirectConsumeMessageDTO directConsumeRequest() {
+        DirectConsumeMessageDTO request = new DirectConsumeMessageDTO();
+        request.setInstanceId(STUDIO_INSTANCE_ID);
+        request.setTopic("orders");
+        request.setMsgId("msg-1");
+        request.setConsumerGroup("billing");
+        request.setClientId("client-a");
+        return request;
+    }
+
     private static ListTopicsResponse 
topicsResponse(ListTopicsResponseBody.List... rows) {
         return topicsResponse((long) rows.length, 1L, 100L, rows);
     }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ApacheInstanceProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ApacheInstanceProviderTest.java
index 2c782966c..51bb098ef 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ApacheInstanceProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ApacheInstanceProviderTest.java
@@ -111,6 +111,7 @@ class ApacheInstanceProviderTest {
                 InstanceCapability.MESSAGE_QUERY,
                 InstanceCapability.MESSAGE_TRACE,
                 InstanceCapability.MESSAGE_SEND,
+                InstanceCapability.DIRECT_MESSAGE_CONSUME,
                 InstanceCapability.ACL_MANAGEMENT,
                 InstanceCapability.DLQ_MANAGEMENT);
     }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
index 91393f953..4ee5af80b 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
@@ -44,6 +44,8 @@ import 
com.tencentcloudapi.trocket.v20230308.models.SendMessageRequest;
 import com.tencentcloudapi.trocket.v20230308.models.SendMessageResponse;
 import com.tencentcloudapi.trocket.v20230308.models.SubscriptionData;
 import com.tencentcloudapi.trocket.v20230308.models.TopicItem;
+import 
com.tencentcloudapi.trocket.v20230308.models.VerifyMessageConsumptionRequest;
+import 
com.tencentcloudapi.trocket.v20230308.models.VerifyMessageConsumptionResponse;
 import com.tencentcloudapi.trocket.v20230308.TrocketClient;
 import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
 import org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode;
@@ -60,6 +62,8 @@ import 
org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetPreviewVO;
 import org.apache.rocketmq.studio.instance.group.SubscriptionEntryVO;
 import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
 import org.apache.rocketmq.studio.instance.message.MessageQueryResult;
+import org.apache.rocketmq.studio.instance.message.DirectConsumeMessageDTO;
+import 
org.apache.rocketmq.studio.instance.message.DirectConsumeMessageResultVO;
 import org.apache.rocketmq.studio.instance.message.TraceNodeVO;
 import org.apache.rocketmq.studio.instance.message.TraceRecordVO;
 import org.apache.rocketmq.studio.instance.topic.TopicConsumerVO;
@@ -131,6 +135,7 @@ class TencentInstanceProviderTest {
                 .contains(InstanceCapability.TOPIC_MANAGEMENT,
                         InstanceCapability.MESSAGE_QUERY,
                         InstanceCapability.MESSAGE_SEND,
+                        InstanceCapability.DIRECT_MESSAGE_CONSUME,
                         InstanceCapability.ACL_MANAGEMENT)
                 .doesNotContain(InstanceCapability.DLQ_MANAGEMENT);
     }
@@ -174,6 +179,42 @@ class TencentInstanceProviderTest {
                 .hasMessageContaining("no message id");
     }
 
+    @Test
+    void consumeMessageDirectlyShouldCallTencentVerifyApiTest() throws 
Exception {
+        VerifyMessageConsumptionResponse response = new 
VerifyMessageConsumptionResponse();
+        response.setRequestId("tencent-request-1");
+        when(client.VerifyMessageConsumption(any())).thenReturn(response);
+
+        DirectConsumeMessageResultVO result = 
provider.consumeMessageDirectly(directConsumeRequest());
+
+        ArgumentCaptor<VerifyMessageConsumptionRequest> captor =
+                ArgumentCaptor.forClass(VerifyMessageConsumptionRequest.class);
+        verify(client).VerifyMessageConsumption(captor.capture());
+        VerifyMessageConsumptionRequest request = captor.getValue();
+        assertThat(request.getInstanceId()).isEqualTo(CLOUD_INSTANCE_ID);
+        assertThat(request.getTopic()).isEqualTo("orders");
+        assertThat(request.getMsgId()).isEqualTo("msg-1");
+        assertThat(request.getConsumerGroup()).isEqualTo("billing");
+        assertThat(request.getClientId()).isEqualTo("client-a");
+        assertThat(result.getConsumeResult()).isEqualTo("REQUEST_ACCEPTED");
+        assertThat(result.getRemark())
+                .contains("verification request", "outcome is not returned", 
"tencent-request-1");
+        assertThat(result.getSpentTimeMillis()).isNotNegative();
+        assertThat(result.isOrder()).isFalse();
+        assertThat(result.isAutoCommit()).isFalse();
+    }
+
+    @Test
+    void consumeMessageDirectlyShouldRejectEmptyTencentResponseTest() throws 
Exception {
+        when(client.VerifyMessageConsumption(any())).thenReturn(null);
+
+        assertThatThrownBy(() -> 
provider.consumeMessageDirectly(directConsumeRequest()))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Tencent direct consume returned an empty 
response")
+                .extracting("code")
+                .isEqualTo(502);
+    }
+
     @Test
     void countTopicsShouldClampOversizedTotals() throws Exception {
         DescribeTopicListResponse response = new DescribeTopicListResponse();
@@ -1194,4 +1235,14 @@ class TencentInstanceProviderTest {
         assertThat(trace.getConsumerStatus().get(0).getDeliveryStatus())
                 .isEqualTo(DeliveryStatus.pending);
     }
+
+    private DirectConsumeMessageDTO directConsumeRequest() {
+        DirectConsumeMessageDTO request = new DirectConsumeMessageDTO();
+        request.setInstanceId(STUDIO_INSTANCE_ID);
+        request.setTopic("orders");
+        request.setMsgId("msg-1");
+        request.setConsumerGroup("billing");
+        request.setClientId("client-a");
+        return request;
+    }
 }
diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts
index eab47239b..9bf6f31db 100644
--- a/web/src/api/instance.ts
+++ b/web/src/api/instance.ts
@@ -26,6 +26,7 @@ export type InstanceCapability =
   | 'MESSAGE_QUERY'
   | 'MESSAGE_TRACE'
   | 'MESSAGE_SEND'
+  | 'DIRECT_MESSAGE_CONSUME'
   | 'ACL_MANAGEMENT'
   | 'DLQ_MANAGEMENT';
 
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index b47668d20..2c695edae 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -3109,6 +3109,22 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'message.batchResend': { zh: '批量重发', en: 'Batch Resend' },
   'message.batchExport': { zh: '批量导出', en: 'Batch Export' },
   'message.noMatchResult': { zh: '没有查到符合条件的结果', en: 'No matching results' },
+  'message.directConsumeSelectInstance': {
+    zh: '请先选择实例',
+    en: 'Select an instance first',
+  },
+  'message.directConsumeCapabilityLoading': {
+    zh: '正在确认当前实例是否支持直接消费',
+    en: 'Checking whether this instance supports direct consumption',
+  },
+  'message.directConsumeUnsupported': {
+    zh: '当前实例不支持直接消费',
+    en: 'This instance does not support direct consumption',
+  },
+  'message.directConsumeCapabilityUnavailable': {
+    zh: '无法获取实例能力,直接消费暂不可用',
+    en: 'Instance capabilities are unavailable; direct consumption is 
disabled',
+  },
 
   // ─── DLQ (detailed) ───
   'dlq.subtitle': {
diff --git a/web/src/mock/instances.ts b/web/src/mock/instances.ts
index caf315d4f..e1b1ea7fa 100644
--- a/web/src/mock/instances.ts
+++ b/web/src/mock/instances.ts
@@ -15,7 +15,28 @@
  * limitations under the License.
  */
 
-import type { Instance } from '../api/instance';
+import type { Instance, InstanceCapability } from '../api/instance';
+
+const MOCK_APACHE_PROVIDER_CAPABILITIES = [
+  'TOPIC_MANAGEMENT',
+  'CONSUMER_GROUP_MANAGEMENT',
+  'MESSAGE_QUERY',
+  'MESSAGE_TRACE',
+  'MESSAGE_SEND',
+  'DIRECT_MESSAGE_CONSUME',
+  'ACL_MANAGEMENT',
+  'DLQ_MANAGEMENT',
+] as const satisfies readonly InstanceCapability[];
+
+const MOCK_CLOUD_PROVIDER_CAPABILITIES = [
+  'TOPIC_MANAGEMENT',
+  'CONSUMER_GROUP_MANAGEMENT',
+  'MESSAGE_QUERY',
+  'MESSAGE_TRACE',
+  'MESSAGE_SEND',
+  'DIRECT_MESSAGE_CONSUME',
+  'ACL_MANAGEMENT',
+] as const satisfies readonly InstanceCapability[];
 
 export const mockInstances: Instance[] = [
   {
@@ -74,3 +95,11 @@ export const mockInstances: Instance[] = [
     gmtModified: '2026-08-03 10:00:00',
   },
 ];
+
+export function getMockInstanceCapabilities(instance: Instance): 
InstanceCapability[] {
+  const capabilities =
+    instance.vendor === 'ALIYUN' || instance.vendor === 'TENCENT'
+      ? MOCK_CLOUD_PROVIDER_CAPABILITIES
+      : MOCK_APACHE_PROVIDER_CAPABILITIES;
+  return [...capabilities];
+}
diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx 
b/web/src/pages/instance/__tests__/MessagePage.test.tsx
index 8aa82669b..ddbcc8f30 100644
--- a/web/src/pages/instance/__tests__/MessagePage.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -37,6 +37,9 @@ const topicServiceMocks = vi.hoisted(() => ({
 const instanceFilterMocks = vi.hoisted(() => ({
   useInstanceFilter: vi.fn(),
 }));
+const instanceServiceMocks = vi.hoisted(() => ({
+  getInstanceCapabilities: vi.fn(),
+}));
 
 vi.mock('../../../services/messageService', () => ({
   ...messageServiceMocks,
@@ -52,6 +55,7 @@ vi.mock('../../../services/messageService', () => ({
 vi.mock('../../../hooks/useInstanceFilter', () => instanceFilterMocks);
 
 vi.mock('../../../services/instanceService', () => ({
+  getInstanceCapabilities: instanceServiceMocks.getInstanceCapabilities,
   listInstances: vi.fn().mockResolvedValue([]),
 }));
 vi.mock('../../../services/topicService', () => topicServiceMocks);
@@ -122,6 +126,12 @@ describe('Message page query history', () => {
       selectInstance: vi.fn(),
       instanceOptions: [{ value: 1, label: 'Instance A' }],
     });
+    
instanceServiceMocks.getInstanceCapabilities.mockReset().mockResolvedValue({
+      instanceId: '1',
+      vendor: 'APACHE',
+      accessType: 'DIRECT',
+      capabilities: ['DIRECT_MESSAGE_CONSUME'],
+    });
   });
 
   afterEach(() => {
diff --git a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx 
b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
index 25edbcb94..64d7139ab 100644
--- a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
@@ -33,6 +33,9 @@ const serviceMocks = vi.hoisted(() => ({
 const instanceFilterMocks = vi.hoisted(() => ({
   useInstanceFilter: vi.fn(),
 }));
+const instanceServiceMocks = vi.hoisted(() => ({
+  getInstanceCapabilities: vi.fn(),
+}));
 const historyMocks = vi.hoisted(() => ({
   getQueryHistorySummary: vi.fn(),
   listMessageQueryHistory: vi.fn(),
@@ -59,6 +62,7 @@ vi.mock('../../../hooks/useInstanceFilter', () => 
instanceFilterMocks);
 vi.mock('../../../api/messageHistory', () => historyMocks);
 
 vi.mock('../../../services/instanceService', () => ({
+  getInstanceCapabilities: instanceServiceMocks.getInstanceCapabilities,
   listInstances: vi.fn().mockResolvedValue([]),
 }));
 vi.mock('../../../services/topicService', () => ({
@@ -165,6 +169,12 @@ describe('MessagePage async request ownership', () => {
       selectInstance: vi.fn(),
       instanceOptions: [{ value: 1, label: 'Instance A' }],
     });
+    instanceServiceMocks.getInstanceCapabilities.mockResolvedValue({
+      instanceId: '1',
+      vendor: 'APACHE',
+      accessType: 'DIRECT',
+      capabilities: ['DIRECT_MESSAGE_CONSUME'],
+    });
     vi.spyOn(message, 'success').mockImplementation(vi.fn());
   });
 
@@ -370,6 +380,117 @@ describe('MessagePage async request ownership', () => {
     expect(serviceMocks.consumeMessageDirectly).not.toHaveBeenCalled();
   });
 
+  
it('disablesDirectConsumeWithReasonWhenTheSelectedInstanceDoesNotAdvertiseItTest',
 async () => {
+    instanceServiceMocks.getInstanceCapabilities.mockResolvedValue({
+      instanceId: '1',
+      vendor: 'ALIYUN',
+      accessType: 'CLOUD',
+      capabilities: ['MESSAGE_QUERY'],
+    });
+    serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
+    const user = userEvent.setup();
+    renderPage();
+    await selectTopic(user);
+
+    await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+    const row = await screen.findByRole('row', { name: /message-a/ });
+    await user.click(within(row).getByRole('button', { name: /详情/ }));
+
+    const dialog = await screen.findByRole('dialog', { name: '消息详情' });
+    await waitFor(() =>
+      
expect(instanceServiceMocks.getInstanceCapabilities).toHaveBeenCalledWith(1),
+    );
+    const directConsumeButton = within(dialog).getByRole('button', { name: 
/直接消费/ });
+    expect(directConsumeButton).toBeDisabled();
+    await user.hover(directConsumeButton.parentElement as HTMLElement);
+    expect(await screen.findByText('当前实例不支持直接消费')).toBeInTheDocument();
+  });
+
+  it('disablesDirectConsumeWithReasonWhenCapabilityLoadingFailsTest', async () 
=> {
+    instanceServiceMocks.getInstanceCapabilities.mockRejectedValue(
+      new Error('capability unavailable'),
+    );
+    serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
+    const user = userEvent.setup();
+    renderPage();
+    await selectTopic(user);
+
+    await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+    const row = await screen.findByRole('row', { name: /message-a/ });
+    await user.click(within(row).getByRole('button', { name: /详情/ }));
+
+    const dialog = await screen.findByRole('dialog', { name: '消息详情' });
+    await waitFor(() =>
+      
expect(instanceServiceMocks.getInstanceCapabilities).toHaveBeenCalledWith(1),
+    );
+    const directConsumeButton = within(dialog).getByRole('button', { name: 
/直接消费/ });
+    expect(directConsumeButton).toBeDisabled();
+    await user.hover(directConsumeButton.parentElement as HTMLElement);
+    expect(await screen.findByText('无法获取实例能力,直接消费暂不可用')).toBeInTheDocument();
+  });
+
+  it('ignoresStaleDirectConsumeCapabilityAfterInstanceChangeTest', async () => 
{
+    const first = createDeferred<{
+      instanceId: string;
+      vendor: string;
+      accessType: string;
+      capabilities: string[];
+    }>();
+    const second = createDeferred<{
+      instanceId: string;
+      vendor: string;
+      accessType: string;
+      capabilities: string[];
+    }>();
+    instanceServiceMocks.getInstanceCapabilities
+      .mockReturnValueOnce(first.promise)
+      .mockReturnValueOnce(second.promise);
+    let selectedInstanceId = 'instance-a';
+    instanceFilterMocks.useInstanceFilter.mockImplementation(() => ({
+      selectedInstanceId,
+      selectInstance: vi.fn(),
+      instanceOptions: [
+        { value: 'instance-a', label: 'Instance A' },
+        { value: 'instance-b', label: 'Instance B' },
+      ],
+    }));
+    const view = renderPage();
+    await waitFor(() =>
+      
expect(instanceServiceMocks.getInstanceCapabilities).toHaveBeenCalledWith('instance-a'),
+    );
+
+    selectedInstanceId = 'instance-b';
+    view.rerender(<MessagePageWithProviders />);
+    await waitFor(() =>
+      
expect(instanceServiceMocks.getInstanceCapabilities).toHaveBeenCalledWith('instance-b'),
+    );
+    await act(async () =>
+      second.resolve({
+        instanceId: 'instance-b',
+        vendor: 'ALIYUN',
+        accessType: 'CLOUD',
+        capabilities: ['MESSAGE_QUERY'],
+      }),
+    );
+    await act(async () =>
+      first.resolve({
+        instanceId: 'instance-a',
+        vendor: 'APACHE',
+        accessType: 'DIRECT',
+        capabilities: ['DIRECT_MESSAGE_CONSUME'],
+      }),
+    );
+    serviceMocks.queryMessages.mockResolvedValue([createMessage('message-b')]);
+    const user = userEvent.setup();
+    await selectTopic(user);
+    await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+    const row = await screen.findByRole('row', { name: /message-b/ });
+    await user.click(within(row).getByRole('button', { name: /详情/ }));
+
+    const dialog = await screen.findByRole('dialog', { name: '消息详情' });
+    expect(within(dialog).getByRole('button', { name: /直接消费/ 
})).toBeDisabled();
+  });
+
   it('queries trace by key with a custom trace topic from the trace tab', 
async () => {
     serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
     
serviceMocks.getMessageTraceByKey.mockResolvedValue(createTrace('key-trace'));
diff --git a/web/src/pages/instance/message.tsx 
b/web/src/pages/instance/message.tsx
index c57c09bae..197d403da 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -35,6 +35,7 @@ import {
   Flex,
   Progress,
   Statistic,
+  Tooltip,
   message,
 } from 'antd';
 import {
@@ -68,6 +69,7 @@ import {
   queryMessagePage,
 } from '../../services/messageService';
 import { listTopics } from '../../services/topicService';
+import { getInstanceCapabilities } from '../../services/instanceService';
 import { useInstanceFilter } from '../../hooks/useInstanceFilter';
 import { downloadBlob } from '../../utils/download';
 import { describeThrownMessage } from '../../utils/apiError';
@@ -89,6 +91,7 @@ const { RangePicker } = DatePicker;
 /* ─── Constants ─── */
 
 type QueryMode = 'topic' | 'key' | 'msgid' | 'queue';
+type DirectConsumeCapabilityStatus = 'loading' | 'supported' | 'unsupported' | 
'unavailable';
 
 const QUERY_OPTIONS = [
   { value: 'topic' as const },
@@ -400,6 +403,10 @@ const MessagePageContent = ({
   const [directConsumeGroup, setDirectConsumeGroup] = useState('');
   const [directConsumeClientId, setDirectConsumeClientId] = useState('');
   const [directConsumeSubmitting, setDirectConsumeSubmitting] = 
useState(false);
+  const [directConsumeCapability, setDirectConsumeCapability] = useState<{
+    instanceId: string;
+    status: DirectConsumeCapabilityStatus;
+  } | null>(null);
   const queryGenerationRef = useRef(0);
   // The query whose results the table currently shows. Pagination must re-run 
this
   // committed query, not whatever the form inputs hold at the moment a page 
is clicked.
@@ -416,6 +423,49 @@ const MessagePageContent = ({
     [],
   );
 
+  useEffect(() => {
+    const instanceId = selectedInstanceId;
+    if (!instanceId) return;
+
+    let active = true;
+    void getInstanceCapabilities(instanceId)
+      .then((result) => {
+        if (active) {
+          setDirectConsumeOpen(false);
+          setDirectConsumeCapability({
+            instanceId,
+            status: result.capabilities.includes('DIRECT_MESSAGE_CONSUME')
+              ? 'supported'
+              : 'unsupported',
+          });
+        }
+      })
+      .catch(() => {
+        if (active) {
+          setDirectConsumeOpen(false);
+          setDirectConsumeCapability({ instanceId, status: 'unavailable' });
+        }
+      });
+    return () => {
+      active = false;
+    };
+  }, [selectedInstanceId]);
+
+  const directConsumeCapabilityStatus =
+    directConsumeCapability !== null && directConsumeCapability.instanceId === 
selectedInstanceId
+      ? directConsumeCapability.status
+      : 'loading';
+  const directConsumeSupported = directConsumeCapabilityStatus === 'supported';
+  const directConsumeDisabledReason = !selectedInstanceId
+    ? t('message.directConsumeSelectInstance')
+    : directConsumeCapabilityStatus === 'unavailable'
+      ? t('message.directConsumeCapabilityUnavailable')
+      : directConsumeCapabilityStatus === 'unsupported'
+        ? t('message.directConsumeUnsupported')
+        : directConsumeCapabilityStatus === 'loading'
+          ? t('message.directConsumeCapabilityLoading')
+          : undefined;
+
   useEffect(() => {
     writeMessageTraceTopic(selectedInstanceId, customTraceTopic);
   }, [customTraceTopic, selectedInstanceId]);
@@ -668,6 +718,7 @@ const MessagePageContent = ({
   };
 
   const openDirectConsume = () => {
+    if (!directConsumeSupported) return;
     setDirectConsumeGroup('');
     setDirectConsumeClientId('');
     setDirectConsumeOpen(true);
@@ -1260,14 +1311,18 @@ const MessagePageContent = ({
         footer={
           <Flex justify="flex-end" gap={8}>
             <Button onClick={closeDetail}>{t('common.close')}</Button>
-            <Button
-              type="primary"
-              icon={<SendOutlined />}
-              disabled={!selectedInstanceId || !selectedMsg}
-              onClick={openDirectConsume}
-            >
-              {t('messagePage.directConsume')}
-            </Button>
+            <Tooltip title={directConsumeDisabledReason}>
+              <span>
+                <Button
+                  type="primary"
+                  icon={<SendOutlined />}
+                  disabled={!directConsumeSupported || !selectedInstanceId || 
!selectedMsg}
+                  onClick={openDirectConsume}
+                >
+                  {t('messagePage.directConsume')}
+                </Button>
+              </span>
+            </Tooltip>
           </Flex>
         }
       >
@@ -1276,7 +1331,7 @@ const MessagePageContent = ({
 
       <Modal
         title={t('messagePage.directConsumeTitle')}
-        open={directConsumeOpen}
+        open={directConsumeOpen && directConsumeSupported}
         onCancel={() => setDirectConsumeOpen(false)}
         onOk={() => void handleDirectConsume()}
         confirmLoading={directConsumeSubmitting}
diff --git a/web/src/services/instanceService.test.ts 
b/web/src/services/instanceService.test.ts
index d19c3f29d..50b78ca92 100644
--- a/web/src/services/instanceService.test.ts
+++ b/web/src/services/instanceService.test.ts
@@ -123,11 +123,44 @@ describe('instanceService mock instances', () => {
 
     const second = await getInstanceCapabilities('instance-direct-1');
 
-    expect(second.capabilities).toContain('DLQ_MANAGEMENT');
+    expect(second.capabilities).toEqual(
+      expect.arrayContaining(['MESSAGE_SEND', 'DIRECT_MESSAGE_CONSUME', 
'DLQ_MANAGEMENT']),
+    );
     await expect(getInstanceCapabilities('missing-instance')).rejects.toThrow(
       'Instance not found: missing-instance',
     );
   });
+
+  it('derives provider capabilities for newly created mock instances', async 
() => {
+    const apacheName = 'mock-capability-apache';
+    const tencentName = 'mock-capability-tencent';
+    try {
+      await createInstance({
+        name: apacheName,
+        type: 'DIRECT',
+        endpoint: 'apache:9876',
+        vendor: 'APACHE',
+      });
+      await createInstance({
+        name: tencentName,
+        type: 'CLOUD',
+        endpoint: 'tencent:8080',
+        vendor: 'TENCENT',
+      });
+
+      await expect(getInstanceCapabilities(apacheName)).resolves.toEqual(
+        expect.objectContaining({
+          capabilities: expect.arrayContaining(['DIRECT_MESSAGE_CONSUME', 
'DLQ_MANAGEMENT']),
+        }),
+      );
+      const tencent = await getInstanceCapabilities(tencentName);
+      expect(tencent.capabilities).toContain('DIRECT_MESSAGE_CONSUME');
+      expect(tencent.capabilities).not.toContain('DLQ_MANAGEMENT');
+    } finally {
+      await deleteInstance(apacheName);
+      await deleteInstance(tencentName);
+    }
+  });
 });
 
 describe('instanceService list request dedupe', () => {
diff --git a/web/src/services/instanceService.ts 
b/web/src/services/instanceService.ts
index 7badf7c25..8e66addcb 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -8,7 +8,7 @@ import type {
   UpdateInstanceRequest,
   InstanceCapabilities,
 } from '../api/instance';
-import { mockInstances } from '../mock/instances';
+import { getMockInstanceCapabilities, mockInstances } from '../mock/instances';
 
 // Compile-time switch: mock or real API
 
@@ -21,23 +21,6 @@ function matchesType(instance: Instance, type?: 
Instance['type']) {
   return instance.type === type;
 }
 
-const APACHE_CAPABILITIES: InstanceCapabilities['capabilities'] = [
-  'TOPIC_MANAGEMENT',
-  'CONSUMER_GROUP_MANAGEMENT',
-  'MESSAGE_QUERY',
-  'MESSAGE_TRACE',
-  'ACL_MANAGEMENT',
-  'DLQ_MANAGEMENT',
-];
-
-const CLOUD_CAPABILITIES: InstanceCapabilities['capabilities'] = [
-  'TOPIC_MANAGEMENT',
-  'CONSUMER_GROUP_MANAGEMENT',
-  'MESSAGE_QUERY',
-  'MESSAGE_TRACE',
-  'ACL_MANAGEMENT',
-];
-
 const inflightListRequests = new Map<string, Promise<Instance[]>>();
 
 // A mutation that just completed must not be shadowed by a list request that
@@ -87,7 +70,7 @@ export async function getInstanceCapabilities(instanceId: 
string): Promise<Insta
     instanceId: instance.name,
     vendor,
     accessType: instance.type,
-    capabilities: [...(vendor === 'APACHE' ? APACHE_CAPABILITIES : 
CLOUD_CAPABILITIES)],
+    capabilities: getMockInstanceCapabilities(instance),
   };
 }
 

Reply via email to