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 e68fb3d8 feat: implement aliyun rocketmq openapi provider with cloud
catalog discovery (#1148)
e68fb3d8 is described below
commit e68fb3d80a196757a0c31defc19f222318eaac00
Author: lizhimins <[email protected]>
AuthorDate: Thu Aug 6 20:47:36 2026 +0800
feat: implement aliyun rocketmq openapi provider with cloud catalog
discovery (#1148)
---
.../provider/alibaba/AliyunCatalogController.java | 53 +++
.../provider/alibaba/AliyunCatalogService.java | 155 ++++++++
.../provider/alibaba/AliyunClientFactory.java | 186 +++++++++
.../studio/provider/alibaba/AliyunConverters.java | 341 ++++++++++++++++
.../provider/alibaba/AliyunInstanceProvider.java | 435 ++++++++++++++++++++
.../provider/alibaba/AliyunCatalogServiceTest.java | 213 ++++++++++
.../provider/alibaba/AliyunClientFactoryTest.java | 188 +++++++++
.../alibaba/AliyunInstanceProviderTest.java | 440 +++++++++++++++++++++
8 files changed, 2011 insertions(+)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunCatalogController.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunCatalogController.java
new file mode 100644
index 00000000..25828c94
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunCatalogController.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.provider.alibaba;
+
+import org.apache.rocketmq.studio.common.domain.Result;
+import org.apache.rocketmq.studio.provider.CloudInstanceOptionVO;
+import org.apache.rocketmq.studio.provider.CloudRegionVO;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * REST endpoints for browsing the Aliyun cloud catalog with a stored
credential.
+ */
+@RestController
+@RequestMapping("/api/cloud/aliyun")
+public class AliyunCatalogController {
+
+ private final AliyunCatalogService catalogService;
+
+ public AliyunCatalogController(AliyunCatalogService catalogService) {
+ this.catalogService = catalogService;
+ }
+
+ @GetMapping("/regions")
+ public Result<List<CloudRegionVO>> listRegions(@RequestParam String
credentialId) {
+ return Result.ok(catalogService.listRegions(credentialId));
+ }
+
+ @GetMapping("/instances")
+ public Result<List<CloudInstanceOptionVO>> listInstances(@RequestParam
String credentialId,
+ @RequestParam
String regionId,
+
@RequestParam(required = false) String search) {
+ return Result.ok(catalogService.listCloudInstances(credentialId,
regionId, search));
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunCatalogService.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunCatalogService.java
new file mode 100644
index 00000000..cb1d6311
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunCatalogService.java
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.provider.alibaba;
+
+import com.aliyun.sdk.service.rocketmq20220801.models.GetInstanceRequest;
+import com.aliyun.sdk.service.rocketmq20220801.models.GetInstanceResponse;
+import com.aliyun.sdk.service.rocketmq20220801.models.GetInstanceResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListInstancesRequest;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListInstancesResponse;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListInstancesResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListRegionsRequest;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListRegionsResponse;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListRegionsResponseBody;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.provider.CloudCatalogProvider;
+import org.apache.rocketmq.studio.provider.CloudInstanceDetailVO;
+import org.apache.rocketmq.studio.provider.CloudInstanceOptionVO;
+import org.apache.rocketmq.studio.provider.CloudRegionVO;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Aliyun cloud catalog: regions and commercial RocketMQ 5.x instances visible
to a credential.
+ */
+@Component
+public class AliyunCatalogService implements CloudCatalogProvider {
+
+ /**
+ * Region used to reach the control plane for region-independent
operations such as ListRegions.
+ */
+ static final String DEFAULT_REGION = "cn-hangzhou";
+
+ private final AliyunClientFactory clientFactory;
+
+ public AliyunCatalogService(AliyunClientFactory clientFactory) {
+ this.clientFactory = clientFactory;
+ }
+
+ @Override
+ public InstanceVendor vendor() {
+ return InstanceVendor.ALIYUN;
+ }
+
+ @Override
+ public List<CloudRegionVO> listRegions(String credentialId) {
+ requireNonBlank(credentialId, "credentialId");
+ ListRegionsResponse response = clientFactory.call(credentialId,
DEFAULT_REGION,
+ client ->
client.listRegions(ListRegionsRequest.builder().build()));
+ ListRegionsResponseBody body = response == null ? null :
response.getBody();
+ List<ListRegionsResponseBody.Data> data = body == null ? null :
body.getData();
+ List<CloudRegionVO> regions = new ArrayList<>();
+ if (data == null) {
+ return regions;
+ }
+ for (ListRegionsResponseBody.Data item : data) {
+ if (Boolean.TRUE.equals(item.getSupportRocketmqV5())) {
+ regions.add(AliyunConverters.toRegionVO(item));
+ }
+ }
+ regions.sort(Comparator.comparing(
+ CloudRegionVO::getRegionId,
Comparator.nullsLast(Comparator.naturalOrder())));
+ return regions;
+ }
+
+ @Override
+ public List<CloudInstanceOptionVO> listCloudInstances(String credentialId,
String regionId, String search) {
+ requireNonBlank(credentialId, "credentialId");
+ requireNonBlank(regionId, "regionId");
+ List<ListInstancesResponseBody.List> all =
fetchAllInstances(credentialId, regionId);
+ List<CloudInstanceOptionVO> options = new ArrayList<>();
+ for (ListInstancesResponseBody.List item : all) {
+ CloudInstanceOptionVO vo =
AliyunConverters.toInstanceOptionVO(item);
+ if (matchesSearch(search, vo)) {
+ options.add(vo);
+ }
+ }
+ return options;
+ }
+
+ @Override
+ public CloudInstanceDetailVO getCloudInstance(String credentialId, String
regionId, String cloudInstanceId) {
+ requireNonBlank(credentialId, "credentialId");
+ requireNonBlank(regionId, "regionId");
+ requireNonBlank(cloudInstanceId, "cloudInstanceId");
+ GetInstanceRequest request =
GetInstanceRequest.builder().instanceId(cloudInstanceId).build();
+ GetInstanceResponse response = clientFactory.call(credentialId,
regionId,
+ client -> client.getInstance(request));
+ GetInstanceResponseBody body = response == null ? null :
response.getBody();
+ GetInstanceResponseBody.Data data = body == null ? null :
body.getData();
+ if (data == null) {
+ throw new BusinessException(404, "Aliyun instance not found: " +
cloudInstanceId);
+ }
+ return AliyunConverters.toInstanceDetailVO(data);
+ }
+
+ private List<ListInstancesResponseBody.List> fetchAllInstances(String
credentialId, String regionId) {
+ List<ListInstancesResponseBody.List> all = new ArrayList<>();
+ for (int page = 1; page <= AliyunConverters.MAX_PAGES; page++) {
+ ListInstancesRequest request = ListInstancesRequest.builder()
+ .pageNumber(page)
+ .pageSize(AliyunConverters.PAGE_SIZE)
+ .build();
+ ListInstancesResponse response = clientFactory.call(credentialId,
regionId,
+ client -> client.listInstances(request));
+ ListInstancesResponseBody body = response == null ? null :
response.getBody();
+ ListInstancesResponseBody.Data data = body == null ? null :
body.getData();
+ List<ListInstancesResponseBody.List> list = data == null ? null :
data.getList();
+ if (list == null || list.isEmpty()) {
+ break;
+ }
+ all.addAll(list);
+ if (list.size() < AliyunConverters.PAGE_SIZE) {
+ break;
+ }
+ }
+ return all;
+ }
+
+ private static boolean matchesSearch(String search, CloudInstanceOptionVO
vo) {
+ if (search == null || search.isBlank()) {
+ return true;
+ }
+ String needle = search.toLowerCase(Locale.ROOT);
+ boolean idMatches = vo.getInstanceId() != null
+ &&
vo.getInstanceId().toLowerCase(Locale.ROOT).contains(needle);
+ boolean nameMatches = vo.getInstanceName() != null
+ &&
vo.getInstanceName().toLowerCase(Locale.ROOT).contains(needle);
+ return idMatches || nameMatches;
+ }
+
+ private static void requireNonBlank(String value, String name) {
+ if (value == null || value.isBlank()) {
+ throw new BusinessException(400, name + " is required");
+ }
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunClientFactory.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunClientFactory.java
new file mode 100644
index 00000000..214a56a8
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunClientFactory.java
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.provider.alibaba;
+
+import com.aliyun.auth.credentials.Credential;
+import com.aliyun.auth.credentials.provider.StaticCredentialProvider;
+import com.aliyun.sdk.gateway.pop.exception.PopClientException;
+import com.aliyun.sdk.gateway.pop.exception.PopServerException;
+import com.aliyun.sdk.service.rocketmq20220801.AsyncClient;
+import darabonba.core.client.ClientOverrideConfiguration;
+import darabonba.core.exception.ClientException;
+import darabonba.core.exception.ServerException;
+import jakarta.annotation.PreDestroy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.apache.rocketmq.studio.cloud.credential.CloudCredentialRepository;
+import org.apache.rocketmq.studio.cloud.credential.CloudCredentialVO;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Function;
+
+/**
+ * Builds and caches Aliyun RocketMQ 5.x OpenAPI async clients per
credential#region, and
+ * converts SDK failures into {@link BusinessException} with meaningful
HTTP-style codes.
+ */
+@Component
+public class AliyunClientFactory {
+
+ private static final Logger log =
LoggerFactory.getLogger(AliyunClientFactory.class);
+
+ static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
+ static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(20);
+ static final long DEFAULT_CALL_TIMEOUT_SECONDS = 30L;
+
+ private final CloudCredentialRepository credentialRepository;
+ private final Map<String, AsyncClient> clients = new ConcurrentHashMap<>();
+ private long callTimeoutSeconds = DEFAULT_CALL_TIMEOUT_SECONDS;
+
+ public AliyunClientFactory(CloudCredentialRepository credentialRepository)
{
+ this.credentialRepository = credentialRepository;
+ }
+
+ public AsyncClient client(String credentialId, String region) {
+ String key = cacheKey(credentialId, region);
+ return clients.computeIfAbsent(key, ignored ->
createClient(credentialId, region));
+ }
+
+ /**
+ * Executes an SDK call with a bounded wait and unified exception mapping.
+ */
+ public <T> T call(String credentialId, String region,
Function<AsyncClient, CompletableFuture<T>> action) {
+ AsyncClient client = client(credentialId, region);
+ CompletableFuture<T> future;
+ try {
+ future = action.apply(client);
+ } catch (RuntimeException ex) {
+ throw mapToBusinessException(ex);
+ }
+ try {
+ return future.get(callTimeoutSeconds, TimeUnit.SECONDS);
+ } catch (TimeoutException ex) {
+ throw new BusinessException(504,
+ "Aliyun OpenAPI request timed out after " +
callTimeoutSeconds + " seconds");
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new BusinessException(502, "Aliyun OpenAPI request was
interrupted");
+ } catch (ExecutionException ex) {
+ throw mapToBusinessException(ex.getCause() == null ? ex :
ex.getCause());
+ }
+ }
+
+ @PreDestroy
+ public void close() {
+ clients.values().forEach(AsyncClient::close);
+ clients.clear();
+ }
+
+ void setCallTimeoutSeconds(long callTimeoutSeconds) {
+ this.callTimeoutSeconds = callTimeoutSeconds;
+ }
+
+ static String cacheKey(String credentialId, String region) {
+ return credentialId + "#" + region;
+ }
+
+ static String endpointFor(String region) {
+ return "rocketmq." + region + ".aliyuncs.com";
+ }
+
+ private AsyncClient createClient(String credentialId, String region) {
+ CloudCredentialVO credential =
credentialRepository.findById(credentialId)
+ .orElseThrow(() -> new BusinessException(404, "Cloud
credential not found: " + credentialId));
+ StaticCredentialProvider credentialProvider =
StaticCredentialProvider.create(
+ Credential.builder()
+ .accessKeyId(credential.getAccessKey())
+ .accessKeySecret(credential.getSecretKey())
+ .build());
+ return AsyncClient.builder()
+ .region(region)
+ .credentialsProvider(credentialProvider)
+ .overrideConfiguration(ClientOverrideConfiguration.create()
+ .setEndpointOverride(endpointFor(region))
+ .setConnectTimeout(CONNECT_TIMEOUT)
+ .setResponseTimeout(RESPONSE_TIMEOUT))
+ .build();
+ }
+
+ static BusinessException mapToBusinessException(Throwable raw) {
+ Throwable cause = raw;
+ while ((cause instanceof CompletionException || cause instanceof
ExecutionException)
+ && cause.getCause() != null) {
+ cause = cause.getCause();
+ }
+ if (cause instanceof BusinessException) {
+ return (BusinessException) cause;
+ }
+ Integer statusCode = null;
+ String errCode = null;
+ String message = cause.getMessage();
+ if (cause instanceof PopServerException) {
+ PopServerException ex = (PopServerException) cause;
+ statusCode = ex.getStatusCode();
+ errCode = ex.getErrCode();
+ message = firstNonBlank(ex.getErrMessage(), message);
+ } else if (cause instanceof PopClientException) {
+ PopClientException ex = (PopClientException) cause;
+ statusCode = ex.getStatusCode();
+ errCode = ex.getErrCode();
+ message = firstNonBlank(ex.getErrMessage(), message);
+ } else if (cause instanceof ServerException) {
+ statusCode = ((ServerException) cause).getStatusCode();
+ } else if (cause instanceof ClientException) {
+ statusCode = ((ClientException) cause).getStatusCode();
+ }
+ int status = statusCode == null ? 0 : statusCode;
+ log.warn("Aliyun OpenAPI failure: status={}, errCode={}, message={}",
status, errCode, message);
+ if (status == 401 || "InvalidAccessKeyId".equals(errCode) ||
"SignatureDoesNotMatch".equals(errCode)) {
+ return new BusinessException(401, "Cloud credential is invalid");
+ }
+ if (status == 403) {
+ return new BusinessException(403, defaultIfBlank(message, "Aliyun
OpenAPI access denied"));
+ }
+ if (status == 404 || errCode != null && errCode.contains("NotFound")) {
+ return new BusinessException(404, defaultIfBlank(message, "Aliyun
resource not found"));
+ }
+ String detail = message != null ? message : errCode != null ? errCode
: cause.getClass().getSimpleName();
+ return new BusinessException(502, "Aliyun OpenAPI error: " + detail);
+ }
+
+ private static String firstNonBlank(String first, String second) {
+ if (first != null && !first.isBlank()) {
+ return first;
+ }
+ return second;
+ }
+
+ private static String defaultIfBlank(String value, String fallback) {
+ if (value == null || value.isBlank()) {
+ return fallback;
+ }
+ return value;
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
new file mode 100644
index 00000000..8e501d0b
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
@@ -0,0 +1,341 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.provider.alibaba;
+
+import com.aliyun.sdk.service.rocketmq20220801.models.DataTopicLagMapValue;
+import
com.aliyun.sdk.service.rocketmq20220801.models.GetConsumerGroupLagResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.GetInstanceResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.GetTraceResponseBody;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListConsumerGroupSubscriptionsResponseBody;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListConsumerGroupsResponseBody;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListInstancesResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListMessagesResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListRegionsResponseBody;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListTopicSubscriptionsResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListTopicsResponseBody;
+import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
+import org.apache.rocketmq.studio.common.domain.enums.TopicType;
+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.MessageRecordVO;
+import org.apache.rocketmq.studio.instance.message.TraceNodeVO;
+import org.apache.rocketmq.studio.instance.message.TraceRecordVO;
+import org.apache.rocketmq.studio.instance.topic.TopicConsumerVO;
+import org.apache.rocketmq.studio.instance.topic.TopicVO;
+import org.apache.rocketmq.studio.provider.CloudInstanceDetailVO;
+import org.apache.rocketmq.studio.provider.CloudInstanceOptionVO;
+import org.apache.rocketmq.studio.provider.CloudRegionVO;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Static converters between Aliyun RocketMQ OpenAPI SDK models and Studio VOs.
+ */
+final class AliyunConverters {
+
+ static final int PAGE_SIZE = 100;
+ static final int MAX_PAGES = 5;
+ static final int MESSAGE_PAGE_SIZE = 20;
+ static final int MESSAGE_MAX_PAGES = 5;
+
+ private static final DateTimeFormatter TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private AliyunConverters() {
+ }
+
+ static CloudRegionVO toRegionVO(ListRegionsResponseBody.Data data) {
+ return new CloudRegionVO(data.getRegionId(), data.getRegionName());
+ }
+
+ static CloudInstanceOptionVO
toInstanceOptionVO(ListInstancesResponseBody.List data) {
+ CloudInstanceOptionVO vo = new CloudInstanceOptionVO();
+ vo.setInstanceId(data.getInstanceId());
+ vo.setInstanceName(data.getInstanceName());
+ vo.setStatus(data.getStatus());
+ vo.setRegionId(data.getRegionId());
+ vo.setTopicCount(toInteger(data.getTopicCount()));
+ vo.setGroupCount(toInteger(data.getGroupCount()));
+ vo.setRemark(data.getRemark());
+ return vo;
+ }
+
+ static CloudInstanceDetailVO
toInstanceDetailVO(GetInstanceResponseBody.Data data) {
+ CloudInstanceDetailVO vo = new CloudInstanceDetailVO();
+ vo.setInstanceId(data.getInstanceId());
+ vo.setInstanceName(data.getInstanceName());
+ vo.setStatus(data.getStatus());
+ vo.setRegionId(data.getRegionId());
+ vo.setRemark(data.getRemark());
+ List<CloudInstanceDetailVO.CloudEndpoint> endpoints = new
ArrayList<>();
+ if (data.getNetworkInfo() != null &&
data.getNetworkInfo().getEndpoints() != null) {
+ for (GetInstanceResponseBody.Endpoints endpoint :
data.getNetworkInfo().getEndpoints()) {
+ endpoints.add(new CloudInstanceDetailVO.CloudEndpoint(
+ endpoint.getEndpointType(),
endpoint.getEndpointUrl()));
+ }
+ }
+ vo.setEndpoints(endpoints);
+ return vo;
+ }
+
+ static TopicVO toTopicVO(ListTopicsResponseBody.List data, String
studioInstanceId) {
+ TopicVO vo = new TopicVO();
+ vo.setName(data.getTopicName());
+ vo.setInstanceId(studioInstanceId);
+ vo.setType(toTopicType(data.getMessageType()));
+ vo.setRemark(data.getRemark());
+ vo.setCreatedAt(parseDateTime(data.getCreateTime()));
+ vo.setUpdatedAt(parseDateTime(data.getUpdateTime()));
+ vo.setWriteQueues(0);
+ vo.setReadQueues(0);
+ return vo;
+ }
+
+ static TopicType toTopicType(String messageType) {
+ if (messageType == null) {
+ return null;
+ }
+ switch (messageType.toUpperCase(Locale.ROOT)) {
+ case "NORMAL":
+ return TopicType.NORMAL;
+ case "FIFO":
+ return TopicType.FIFO;
+ case "DELAY":
+ return TopicType.DELAY;
+ case "TRANSACTION":
+ return TopicType.TRANSACTION;
+ default:
+ return null;
+ }
+ }
+
+ static TopicConsumerVO
toTopicConsumerVO(ListTopicSubscriptionsResponseBody.Data data) {
+ return TopicConsumerVO.builder()
+ .group(data.getConsumerGroupId())
+ .consumeType(toConsumeType(data.getMessageModel()))
+ .messageModel(data.getMessageModel())
+ .build();
+ }
+
+ static ConsumerGroupVO
toConsumerGroupVO(ListConsumerGroupsResponseBody.List data, String
studioInstanceId) {
+ ConsumerGroupVO vo = new ConsumerGroupVO();
+ vo.setName(data.getConsumerGroupId());
+ vo.setInstanceId(studioInstanceId);
+ vo.setConsumeType(toConsumeType(data.getMessageModel()));
+ vo.setCreatedAt(parseDateTime(data.getCreateTime()));
+ vo.setUpdatedAt(parseDateTime(data.getUpdateTime()));
+ return vo;
+ }
+
+ static ConsumeType toConsumeType(String messageModel) {
+ if (messageModel == null) {
+ return null;
+ }
+ if ("Clustering".equalsIgnoreCase(messageModel)) {
+ return ConsumeType.CLUSTERING;
+ }
+ if ("Broadcasting".equalsIgnoreCase(messageModel)) {
+ return ConsumeType.BROADCASTING;
+ }
+ return null;
+ }
+
+ static List<QueueProgressVO>
toQueueProgressRows(GetConsumerGroupLagResponseBody.Data data) {
+ List<QueueProgressVO> rows = new ArrayList<>();
+ Map<String, DataTopicLagMapValue> topicLagMap = data.getTopicLagMap();
+ if (topicLagMap != null) {
+ for (Map.Entry<String, DataTopicLagMapValue> entry :
topicLagMap.entrySet()) {
+ long ready = entry.getValue() == null ||
entry.getValue().getReadyCount() == null
+ ? 0L : entry.getValue().getReadyCount();
+ rows.add(QueueProgressVO.builder()
+ .broker("topic:" + entry.getKey())
+ .queueId(0)
+ .brokerOffset(0L)
+ .consumerOffset(0L)
+ .diffTotal(ready)
+ .build());
+ }
+ }
+ GetConsumerGroupLagResponseBody.TotalLag totalLag = data.getTotalLag();
+ if (totalLag != null && totalLag.getReadyCount() != null) {
+ rows.add(QueueProgressVO.builder()
+ .broker("total")
+ .queueId(0)
+ .brokerOffset(0L)
+ .consumerOffset(0L)
+ .diffTotal(totalLag.getReadyCount())
+ .build());
+ }
+ return rows;
+ }
+
+ static SubscriptionEntryVO
toSubscriptionEntry(ListConsumerGroupSubscriptionsResponseBody.Data data) {
+ return SubscriptionEntryVO.builder()
+ .topic(data.getTopicName())
+ .expression(data.getFilterExpression())
+ .type(data.getFilterExpressionType())
+ .consistency(data.getConsistency() == null ? null :
String.valueOf(data.getConsistency()))
+ .build();
+ }
+
+ static MessageRecordVO toMessageRecord(ListMessagesResponseBody.List data)
{
+ String rawBody = data.getBody();
+ String decodedBody = tryBase64Decode(rawBody);
+ MessageRecordVO.MessageRecordVOBuilder builder =
MessageRecordVO.builder()
+ .msgId(data.getMessageId())
+ .topic(data.getTopicName())
+ .tag(data.getMessageTag())
+ .key(data.getMessageKeys() == null ? null : String.join(" ",
data.getMessageKeys()))
+ .bornHost(data.getBornHost())
+ .storeHost(data.getStoreHost())
+ .storeTime(parseTimeMillis(data.getStoreTime()))
+ .properties(data.getUserProperties())
+ .size(data.getBodySize() == null ? 0 : data.getBodySize());
+ if (decodedBody != null) {
+ builder.body(decodedBody).bodyEncoding("UTF-8");
+ } else {
+ builder.body(rawBody).bodyEncoding("TEXT");
+ }
+ return builder.build();
+ }
+
+ static TraceRecordVO toTraceRecord(GetTraceResponseBody.Data data) {
+ List<TraceNodeVO> nodes = new ArrayList<>();
+ if (data.getProducerInfo() != null &&
data.getProducerInfo().getRecords() != null) {
+ for (GetTraceResponseBody.ProducerInfoRecords record :
data.getProducerInfo().getRecords()) {
+ nodes.add(TraceNodeVO.builder()
+ .title("Producer")
+ .timestamp(parseTimeMillis(record.getProduceTime()))
+ .status(record.getProduceStatus())
+ .costTime(record.getProduceDuration() == null ? 0L :
record.getProduceDuration())
+ .description(joinParts(", ", record.getClientHost(),
record.getMessageSource()))
+ .build());
+ }
+ }
+ if (data.getBrokerInfo() != null &&
data.getBrokerInfo().getOperations() != null) {
+ for (GetTraceResponseBody.Operations operation :
data.getBrokerInfo().getOperations()) {
+ nodes.add(TraceNodeVO.builder()
+ .title("Broker " + operation.getOperateType())
+ .timestamp(parseTimeMillis(operation.getOperateTime()))
+ .build());
+ }
+ }
+ if (data.getConsumerInfos() != null) {
+ for (GetTraceResponseBody.ConsumerInfos consumerInfo :
data.getConsumerInfos()) {
+ if (consumerInfo.getRecords() == null ||
consumerInfo.getRecords().isEmpty()) {
+ nodes.add(TraceNodeVO.builder()
+ .title("Consumer " +
consumerInfo.getConsumerGroupId())
+ .status(consumerInfo.getConsumeStatus())
+ .build());
+ continue;
+ }
+ for (GetTraceResponseBody.Records record :
consumerInfo.getRecords()) {
+ String operateTime = null;
+ if (record.getOperations() != null &&
!record.getOperations().isEmpty()) {
+ operateTime =
record.getOperations().get(0).getOperateTime();
+ }
+ nodes.add(TraceNodeVO.builder()
+ .title("Consumer " +
consumerInfo.getConsumerGroupId())
+ .timestamp(parseTimeMillis(operateTime))
+ .status(record.getConsumeStatus())
+ .description(joinParts(", ",
record.getClientHost(), record.getUserName()))
+ .build());
+ }
+ }
+ }
+ return TraceRecordVO.builder().nodes(nodes).build();
+ }
+
+ static java.time.LocalDateTime parseDateTime(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ try {
+ return java.time.LocalDateTime.parse(value, TIME_FORMATTER);
+ } catch (RuntimeException ex) {
+ return null;
+ }
+ }
+
+ static long parseTimeMillis(String value) {
+ if (value == null || value.isBlank()) {
+ return 0L;
+ }
+ try {
+ LocalDateTime dateTime = LocalDateTime.parse(value,
TIME_FORMATTER);
+ return
dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+ } catch (RuntimeException ignored) {
+ return 0L;
+ }
+ }
+
+ static String formatTimeMillis(long epochMillis) {
+ return TIME_FORMATTER.format(
+ LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis),
ZoneId.systemDefault()));
+ }
+
+ static String tryBase64Decode(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return null;
+ }
+ byte[] bytes;
+ try {
+ bytes = Base64.getDecoder().decode(raw);
+ } catch (IllegalArgumentException ignored) {
+ return null;
+ }
+ try {
+ return StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(bytes))
+ .toString();
+ } catch (CharacterCodingException ignored) {
+ return null;
+ }
+ }
+
+ private static String joinParts(String separator, String... parts) {
+ StringBuilder sb = new StringBuilder();
+ for (String part : parts) {
+ if (part == null || part.isBlank()) {
+ continue;
+ }
+ if (sb.length() > 0) {
+ sb.append(separator);
+ }
+ sb.append(part);
+ }
+ return sb.length() == 0 ? null : sb.toString();
+ }
+
+ private static Integer toInteger(Long value) {
+ return value == null ? null : value.intValue();
+ }
+}
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
new file mode 100644
index 00000000..04fb8788
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProvider.java
@@ -0,0 +1,435 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.provider.alibaba;
+
+import
com.aliyun.sdk.service.rocketmq20220801.models.CreateConsumerGroupRequest;
+import com.aliyun.sdk.service.rocketmq20220801.models.CreateTopicRequest;
+import
com.aliyun.sdk.service.rocketmq20220801.models.DeleteConsumerGroupRequest;
+import com.aliyun.sdk.service.rocketmq20220801.models.DeleteTopicRequest;
+import
com.aliyun.sdk.service.rocketmq20220801.models.GetConsumerGroupLagRequest;
+import
com.aliyun.sdk.service.rocketmq20220801.models.GetConsumerGroupLagResponse;
+import
com.aliyun.sdk.service.rocketmq20220801.models.GetConsumerGroupLagResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.GetTraceRequest;
+import com.aliyun.sdk.service.rocketmq20220801.models.GetTraceResponse;
+import com.aliyun.sdk.service.rocketmq20220801.models.GetTraceResponseBody;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListConsumerGroupSubscriptionsRequest;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListConsumerGroupSubscriptionsResponse;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListConsumerGroupSubscriptionsResponseBody;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListConsumerGroupsRequest;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListConsumerGroupsResponse;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListConsumerGroupsResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListMessagesRequest;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListMessagesResponse;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListMessagesResponseBody;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListTopicSubscriptionsRequest;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListTopicSubscriptionsResponse;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListTopicSubscriptionsResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListTopicsRequest;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListTopicsResponse;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListTopicsResponseBody;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ResetConsumeOffsetRequest;
+import com.aliyun.sdk.service.rocketmq20220801.models.UpdateTopicRequest;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.instance.InstanceRepository;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+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.MessageRecordVO;
+import org.apache.rocketmq.studio.instance.message.TraceRecordVO;
+import org.apache.rocketmq.studio.instance.topic.TopicConsumerVO;
+import org.apache.rocketmq.studio.instance.topic.TopicVO;
+import org.apache.rocketmq.studio.provider.InstanceProvider;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Aliyun RocketMQ 5.x implementation of the instance-scoped operations SPI,
backed by the
+ * OpenAPI async SDK through {@link AliyunClientFactory}.
+ */
+@Component
+public class AliyunInstanceProvider implements InstanceProvider {
+
+ private static final String DEFAULT_DELIVERY_ORDER_TYPE = "Concurrently";
+ private static final String ORDERLY_DELIVERY_ORDER_TYPE = "Orderly";
+ private static final String DEFAULT_RETRY_POLICY = "DefaultRetryPolicy";
+ private static final String FIXED_RETRY_POLICY = "FixedRetryPolicy";
+ private static final int DEFAULT_MAX_RETRY_TIMES = 16;
+ private static final int DEFAULT_FIXED_RETRY_INTERVAL_SECONDS = 10;
+ private static final String RESET_TYPE_SPECIFIED_TIME = "SPECIFIED_TIME";
+ private static final String RESET_TYPE_LATEST_OFFSET = "LATEST_OFFSET";
+
+ private final AliyunClientFactory clientFactory;
+ private final InstanceRepository instanceRepository;
+
+ public AliyunInstanceProvider(AliyunClientFactory clientFactory,
InstanceRepository instanceRepository) {
+ this.clientFactory = clientFactory;
+ this.instanceRepository = instanceRepository;
+ }
+
+ @Override
+ public InstanceVendor vendor() {
+ return InstanceVendor.ALIYUN;
+ }
+
+ @Override
+ public List<TopicVO> listTopics(String instanceId, String type, String
search) {
+ Context ctx = resolve(instanceId);
+ List<ListTopicsResponseBody.List> all = new ArrayList<>();
+ for (int page = 1; page <= AliyunConverters.MAX_PAGES; page++) {
+ ListTopicsRequest.Builder builder = ListTopicsRequest.builder()
+ .instanceId(ctx.cloudInstanceId())
+ .pageNumber(page)
+ .pageSize(AliyunConverters.PAGE_SIZE);
+ if (!isBlank(search)) {
+ builder.filter(search);
+ }
+ ListTopicsRequest request = builder.build();
+ ListTopicsResponse response =
clientFactory.call(ctx.credentialId(), ctx.regionId(),
+ client -> client.listTopics(request));
+ ListTopicsResponseBody body = response == null ? null :
response.getBody();
+ ListTopicsResponseBody.Data data = body == null ? null :
body.getData();
+ List<ListTopicsResponseBody.List> list = data == null ? null :
data.getList();
+ if (list == null || list.isEmpty()) {
+ break;
+ }
+ all.addAll(list);
+ if (list.size() < AliyunConverters.PAGE_SIZE) {
+ break;
+ }
+ }
+ List<TopicVO> topics = new ArrayList<>();
+ for (ListTopicsResponseBody.List item : all) {
+ TopicVO vo = AliyunConverters.toTopicVO(item, instanceId);
+ if (matchesType(type, vo)) {
+ topics.add(vo);
+ }
+ }
+ return topics;
+ }
+
+ @Override
+ public TopicVO createTopic(String instanceId, TopicVO topic) {
+ Context ctx = resolve(instanceId);
+ if (topic == null || isBlank(topic.getName())) {
+ throw new BusinessException(400, "Topic name is required");
+ }
+ if (topic.getType() == null) {
+ throw new BusinessException(400, "Topic type is required");
+ }
+ CreateTopicRequest request = CreateTopicRequest.builder()
+ .instanceId(ctx.cloudInstanceId())
+ .topicName(topic.getName())
+ .messageType(topic.getType().name())
+ .remark(topic.getRemark())
+ .build();
+ clientFactory.call(ctx.credentialId(), ctx.regionId(), client ->
client.createTopic(request));
+ topic.setInstanceId(instanceId);
+ topic.setCreatedAt(java.time.LocalDateTime.now());
+ topic.setUpdatedAt(java.time.LocalDateTime.now());
+ return topic;
+ }
+
+ @Override
+ public TopicVO updateTopic(String instanceId, TopicVO topic) {
+ Context ctx = resolve(instanceId);
+ if (topic == null || isBlank(topic.getName())) {
+ throw new BusinessException(400, "Topic name is required");
+ }
+ UpdateTopicRequest request = UpdateTopicRequest.builder()
+ .instanceId(ctx.cloudInstanceId())
+ .topicName(topic.getName())
+ .remark(topic.getRemark())
+ .build();
+ clientFactory.call(ctx.credentialId(), ctx.regionId(), client ->
client.updateTopic(request));
+ topic.setInstanceId(instanceId);
+ return topic;
+ }
+
+ @Override
+ public void deleteTopic(String instanceId, String topicName) {
+ Context ctx = resolve(instanceId);
+ DeleteTopicRequest request = DeleteTopicRequest.builder()
+ .instanceId(ctx.cloudInstanceId())
+ .topicName(topicName)
+ .build();
+ clientFactory.call(ctx.credentialId(), ctx.regionId(), client ->
client.deleteTopic(request));
+ }
+
+ @Override
+ public List<TopicConsumerVO> getTopicConsumers(String instanceId, String
topicName) {
+ Context ctx = resolve(instanceId);
+ ListTopicSubscriptionsRequest request =
ListTopicSubscriptionsRequest.builder()
+ .instanceId(ctx.cloudInstanceId())
+ .topicName(topicName)
+ .build();
+ ListTopicSubscriptionsResponse response =
clientFactory.call(ctx.credentialId(), ctx.regionId(),
+ client -> client.listTopicSubscriptions(request));
+ ListTopicSubscriptionsResponseBody body = response == null ? null :
response.getBody();
+ List<ListTopicSubscriptionsResponseBody.Data> data = body == null ?
null : body.getData();
+ List<TopicConsumerVO> consumers = new ArrayList<>();
+ if (data == null) {
+ return consumers;
+ }
+ for (ListTopicSubscriptionsResponseBody.Data item : data) {
+ consumers.add(AliyunConverters.toTopicConsumerVO(item));
+ }
+ return consumers;
+ }
+
+ @Override
+ public List<ConsumerGroupVO> listConsumerGroups(String instanceId, String
search) {
+ Context ctx = resolve(instanceId);
+ List<ListConsumerGroupsResponseBody.List> all = new ArrayList<>();
+ for (int page = 1; page <= AliyunConverters.MAX_PAGES; page++) {
+ ListConsumerGroupsRequest.Builder builder =
ListConsumerGroupsRequest.builder()
+ .instanceId(ctx.cloudInstanceId())
+ .pageNumber(page)
+ .pageSize(AliyunConverters.PAGE_SIZE);
+ if (!isBlank(search)) {
+ builder.filter(search);
+ }
+ ListConsumerGroupsRequest request = builder.build();
+ ListConsumerGroupsResponse response =
clientFactory.call(ctx.credentialId(), ctx.regionId(),
+ client -> client.listConsumerGroups(request));
+ ListConsumerGroupsResponseBody body = response == null ? null :
response.getBody();
+ ListConsumerGroupsResponseBody.Data data = body == null ? null :
body.getData();
+ List<ListConsumerGroupsResponseBody.List> list = data == null ?
null : data.getList();
+ if (list == null || list.isEmpty()) {
+ break;
+ }
+ all.addAll(list);
+ if (list.size() < AliyunConverters.PAGE_SIZE) {
+ break;
+ }
+ }
+ List<ConsumerGroupVO> groups = new ArrayList<>();
+ for (ListConsumerGroupsResponseBody.List item : all) {
+ groups.add(AliyunConverters.toConsumerGroupVO(item, instanceId));
+ }
+ return groups;
+ }
+
+ @Override
+ public ConsumerGroupVO createConsumerGroup(String instanceId,
ConsumerGroupVO group) {
+ Context ctx = resolve(instanceId);
+ if (group == null || isBlank(group.getName())) {
+ throw new BusinessException(400, "Consumer group name is
required");
+ }
+ String deliveryOrderType =
normalizeDeliveryOrderType(group.getDeliveryOrderType());
+ int maxRetryTimes = group.getRetryMaxTimes() > 0 ?
group.getRetryMaxTimes() : DEFAULT_MAX_RETRY_TIMES;
+ CreateConsumerGroupRequest.ConsumeRetryPolicy.Builder retryPolicy =
+ CreateConsumerGroupRequest.ConsumeRetryPolicy.builder()
+ .maxRetryTimes(maxRetryTimes);
+ if (ORDERLY_DELIVERY_ORDER_TYPE.equals(deliveryOrderType)) {
+ // ordered groups reject DefaultRetryPolicy and require a fixed
interval
+ retryPolicy.retryPolicy(FIXED_RETRY_POLICY)
+
.fixedIntervalRetryTime(DEFAULT_FIXED_RETRY_INTERVAL_SECONDS);
+ } else {
+ retryPolicy.retryPolicy(DEFAULT_RETRY_POLICY);
+ }
+ CreateConsumerGroupRequest request =
CreateConsumerGroupRequest.builder()
+ .instanceId(ctx.cloudInstanceId())
+ .consumerGroupId(group.getName())
+ .deliveryOrderType(deliveryOrderType)
+ .consumeRetryPolicy(retryPolicy.build())
+ .build();
+ clientFactory.call(ctx.credentialId(), ctx.regionId(), client ->
client.createConsumerGroup(request));
+ group.setInstanceId(instanceId);
+ group.setDeliveryOrderType(deliveryOrderType);
+ group.setRetryMaxTimes(maxRetryTimes);
+ group.setSubscribedTopics(java.util.List.of());
+ group.setCreatedAt(java.time.LocalDateTime.now());
+ group.setUpdatedAt(java.time.LocalDateTime.now());
+ return group;
+ }
+
+ /**
+ * OpenAPI accepts Concurrently/Orderly; tolerate FIFO/ordered spellings
from the UI.
+ */
+ static String normalizeDeliveryOrderType(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return DEFAULT_DELIVERY_ORDER_TYPE;
+ }
+ String value = raw.trim();
+ if ("FIFO".equalsIgnoreCase(value) ||
"ORDERLY".equalsIgnoreCase(value)) {
+ return ORDERLY_DELIVERY_ORDER_TYPE;
+ }
+ return DEFAULT_DELIVERY_ORDER_TYPE;
+ }
+
+ @Override
+ public void deleteConsumerGroup(String instanceId, String groupName) {
+ Context ctx = resolve(instanceId);
+ DeleteConsumerGroupRequest request =
DeleteConsumerGroupRequest.builder()
+ .instanceId(ctx.cloudInstanceId())
+ .consumerGroupId(groupName)
+ .build();
+ clientFactory.call(ctx.credentialId(), ctx.regionId(), client ->
client.deleteConsumerGroup(request));
+ }
+
+ @Override
+ public List<QueueProgressVO> getGroupProgress(String instanceId, String
groupName) {
+ Context ctx = resolve(instanceId);
+ GetConsumerGroupLagRequest request =
GetConsumerGroupLagRequest.builder()
+ .instanceId(ctx.cloudInstanceId())
+ .consumerGroupId(groupName)
+ .build();
+ GetConsumerGroupLagResponse response =
clientFactory.call(ctx.credentialId(), ctx.regionId(),
+ client -> client.getConsumerGroupLag(request));
+ GetConsumerGroupLagResponseBody body = response == null ? null :
response.getBody();
+ GetConsumerGroupLagResponseBody.Data data = body == null ? null :
body.getData();
+ if (data == null) {
+ return new ArrayList<>();
+ }
+ return AliyunConverters.toQueueProgressRows(data);
+ }
+
+ @Override
+ public List<SubscriptionEntryVO> getGroupSubscriptions(String instanceId,
String groupName) {
+ Context ctx = resolve(instanceId);
+ ListConsumerGroupSubscriptionsRequest request =
ListConsumerGroupSubscriptionsRequest.builder()
+ .instanceId(ctx.cloudInstanceId())
+ .consumerGroupId(groupName)
+ .build();
+ ListConsumerGroupSubscriptionsResponse response =
clientFactory.call(ctx.credentialId(), ctx.regionId(),
+ client -> client.listConsumerGroupSubscriptions(request));
+ ListConsumerGroupSubscriptionsResponseBody body = response == null ?
null : response.getBody();
+ List<ListConsumerGroupSubscriptionsResponseBody.Data> data = body ==
null ? null : body.getData();
+ List<SubscriptionEntryVO> subscriptions = new ArrayList<>();
+ if (data == null) {
+ return subscriptions;
+ }
+ for (ListConsumerGroupSubscriptionsResponseBody.Data item : data) {
+ subscriptions.add(AliyunConverters.toSubscriptionEntry(item));
+ }
+ return subscriptions;
+ }
+
+ @Override
+ public void resetOffset(String instanceId, String groupName, long
timestamp, String topic) {
+ Context ctx = resolve(instanceId);
+ ResetConsumeOffsetRequest.Builder builder =
ResetConsumeOffsetRequest.builder()
+ .instanceId(ctx.cloudInstanceId())
+ .consumerGroupId(groupName);
+ if (!isBlank(topic)) {
+ builder.topicName(topic);
+ }
+ if (timestamp > 0L) {
+ builder.resetType(RESET_TYPE_SPECIFIED_TIME)
+ .resetTime(AliyunConverters.formatTimeMillis(timestamp));
+ } else {
+ builder.resetType(RESET_TYPE_LATEST_OFFSET);
+ }
+ ResetConsumeOffsetRequest request = builder.build();
+ clientFactory.call(ctx.credentialId(), ctx.regionId(), client ->
client.resetConsumeOffset(request));
+ }
+
+ @Override
+ public List<MessageRecordVO> queryMessages(String instanceId, String
topic, String msgId,
+ String tag, String key, Long
startTime, Long endTime) {
+ Context ctx = resolve(instanceId);
+ List<MessageRecordVO> records = new ArrayList<>();
+ for (int page = 1; page <= AliyunConverters.MESSAGE_MAX_PAGES; page++)
{
+ ListMessagesRequest.Builder builder = ListMessagesRequest.builder()
+ .instanceId(ctx.cloudInstanceId())
+ .pageNumber(page)
+ .pageSize(AliyunConverters.MESSAGE_PAGE_SIZE);
+ if (!isBlank(topic)) {
+ builder.topicName(topic);
+ }
+ if (!isBlank(msgId)) {
+ builder.messageId(msgId);
+ }
+ if (!isBlank(key)) {
+ builder.messageKey(key);
+ }
+ if (startTime != null) {
+
builder.startTime(AliyunConverters.formatTimeMillis(startTime));
+ }
+ if (endTime != null) {
+ builder.endTime(AliyunConverters.formatTimeMillis(endTime));
+ }
+ ListMessagesRequest request = builder.build();
+ ListMessagesResponse response =
clientFactory.call(ctx.credentialId(), ctx.regionId(),
+ client -> client.listMessages(request));
+ ListMessagesResponseBody body = response == null ? null :
response.getBody();
+ ListMessagesResponseBody.Data data = body == null ? null :
body.getData();
+ List<ListMessagesResponseBody.List> list = data == null ? null :
data.getList();
+ if (list == null || list.isEmpty()) {
+ break;
+ }
+ for (ListMessagesResponseBody.List item : list) {
+ MessageRecordVO vo = AliyunConverters.toMessageRecord(item);
+ if (isBlank(tag) || tag.equals(vo.getTag())) {
+ records.add(vo);
+ }
+ }
+ if (list.size() < AliyunConverters.MESSAGE_PAGE_SIZE) {
+ break;
+ }
+ }
+ return records;
+ }
+
+ @Override
+ public TraceRecordVO getMessageTrace(String instanceId, String msgId) {
+ Context ctx = resolve(instanceId);
+ GetTraceRequest request = GetTraceRequest.builder()
+ .instanceId(ctx.cloudInstanceId())
+ .messageId(msgId)
+ .build();
+ GetTraceResponse response = clientFactory.call(ctx.credentialId(),
ctx.regionId(),
+ client -> client.getTrace(request));
+ GetTraceResponseBody body = response == null ? null :
response.getBody();
+ GetTraceResponseBody.Data data = body == null ? null : body.getData();
+ if (data == null) {
+ throw new BusinessException(404, "Message trace not found: " +
msgId);
+ }
+ return AliyunConverters.toTraceRecord(data);
+ }
+
+ private Context resolve(String instanceId) {
+ if (isBlank(instanceId)) {
+ throw new BusinessException(400, "instanceId is required");
+ }
+ InstanceVO instance = instanceRepository.findById(instanceId)
+ .orElseThrow(() -> new BusinessException(404, "Instance not
found: " + instanceId));
+ if (isBlank(instance.getCloudInstanceId()) ||
isBlank(instance.getRegionId())
+ || isBlank(instance.getCredentialId())) {
+ throw new BusinessException(400, "Instance " + instanceId + " is
missing Aliyun cloud binding");
+ }
+ return new Context(instance.getCloudInstanceId(),
instance.getRegionId(), instance.getCredentialId());
+ }
+
+ private static boolean matchesType(String type, TopicVO vo) {
+ if (isBlank(type)) {
+ return true;
+ }
+ return vo.getType() != null &&
vo.getType().name().equalsIgnoreCase(type.trim());
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+
+ private record Context(String cloudInstanceId, String regionId, String
credentialId) {
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunCatalogServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunCatalogServiceTest.java
new file mode 100644
index 00000000..cacd8388
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunCatalogServiceTest.java
@@ -0,0 +1,213 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.provider.alibaba;
+
+import com.aliyun.sdk.service.rocketmq20220801.models.GetInstanceResponse;
+import com.aliyun.sdk.service.rocketmq20220801.models.GetInstanceResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListInstancesResponse;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListInstancesResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListRegionsResponse;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListRegionsResponseBody;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.provider.CloudInstanceDetailVO;
+import org.apache.rocketmq.studio.provider.CloudInstanceOptionVO;
+import org.apache.rocketmq.studio.provider.CloudRegionVO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AliyunCatalogServiceTest {
+
+ private static final String CREDENTIAL_ID = "cred-1";
+ private static final String REGION = "cn-hangzhou";
+
+ @Mock
+ private AliyunClientFactory clientFactory;
+
+ private AliyunCatalogService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new AliyunCatalogService(clientFactory);
+ }
+
+ @Test
+ void listRegionsShouldKeepOnlyRocketmqV5RegionsTest() {
+ ListRegionsResponse response = ListRegionsResponse.create().toBuilder()
+ .statusCode(200)
+ .body(ListRegionsResponseBody.builder()
+ .data(List.of(
+ ListRegionsResponseBody.Data.builder()
+
.regionId("cn-shanghai").regionName("shanghai")
+ .supportRocketmqV5(true).build(),
+ ListRegionsResponseBody.Data.builder()
+
.regionId("cn-hangzhou").regionName("hangzhou")
+ .supportRocketmqV5(true).build(),
+ ListRegionsResponseBody.Data.builder()
+
.regionId("cn-beijing").regionName("beijing")
+ .supportRocketmqV5(true).build(),
+ ListRegionsResponseBody.Data.builder()
+
.regionId("cn-legacy").regionName("legacy")
+ .supportRocketmqV5(false).build()))
+ .build())
+ .build();
+ when(clientFactory.call(eq(CREDENTIAL_ID),
eq(AliyunCatalogService.DEFAULT_REGION), any()))
+ .thenReturn(response);
+
+ List<CloudRegionVO> regions = service.listRegions(CREDENTIAL_ID);
+
+ assertThat(regions).hasSize(3);
+ assertThat(regions).extracting(CloudRegionVO::getRegionId)
+ .containsExactly("cn-beijing", "cn-hangzhou", "cn-shanghai");
+ assertThat(regions.get(1).getRegionName()).isEqualTo("hangzhou");
+ }
+
+ @Test
+ void listCloudInstancesShouldAggregatePagesTest() {
+ ListInstancesResponse firstPage =
instancesResponse(instanceRows(AliyunConverters.PAGE_SIZE, 0));
+ ListInstancesResponse secondPage = instancesResponse(instanceRows(3,
AliyunConverters.PAGE_SIZE));
+ when(clientFactory.call(eq(CREDENTIAL_ID), eq(REGION), any()))
+ .thenReturn(firstPage, secondPage);
+
+ List<CloudInstanceOptionVO> options =
service.listCloudInstances(CREDENTIAL_ID, REGION, null);
+
+ assertThat(options).hasSize(AliyunConverters.PAGE_SIZE + 3);
+ verify(clientFactory, times(2)).call(eq(CREDENTIAL_ID), eq(REGION),
any());
+ }
+
+ @Test
+ void listCloudInstancesShouldStopAtMaxPagesTest() {
+ ListInstancesResponse fullPage =
instancesResponse(instanceRows(AliyunConverters.PAGE_SIZE, 0));
+ when(clientFactory.call(eq(CREDENTIAL_ID), eq(REGION),
any())).thenReturn(fullPage);
+
+ List<CloudInstanceOptionVO> options =
service.listCloudInstances(CREDENTIAL_ID, REGION, null);
+
+ assertThat(options).hasSize(AliyunConverters.PAGE_SIZE *
AliyunConverters.MAX_PAGES);
+ verify(clientFactory,
times(AliyunConverters.MAX_PAGES)).call(eq(CREDENTIAL_ID), eq(REGION), any());
+ }
+
+ @Test
+ void listCloudInstancesShouldApplySearchFilterTest() {
+ List<ListInstancesResponseBody.List> rows = new ArrayList<>();
+ rows.add(instanceRow("rmq-prod-001", "Production"));
+ rows.add(instanceRow("rmq-test-002", "Staging"));
+ when(clientFactory.call(eq(CREDENTIAL_ID), eq(REGION), any()))
+ .thenReturn(instancesResponse(rows));
+
+ List<CloudInstanceOptionVO> byId =
service.listCloudInstances(CREDENTIAL_ID, REGION, "TEST-002");
+ List<CloudInstanceOptionVO> byName =
service.listCloudInstances(CREDENTIAL_ID, REGION, "staging");
+
+ assertThat(byId).hasSize(1);
+ assertThat(byId.get(0).getInstanceId()).isEqualTo("rmq-test-002");
+ assertThat(byName).hasSize(1);
+ assertThat(byName.get(0).getInstanceName()).isEqualTo("Staging");
+ }
+
+ @Test
+ void listCloudInstancesShouldRequireRegionTest() {
+ assertThatThrownBy(() -> service.listCloudInstances(CREDENTIAL_ID, "
", null))
+ .isInstanceOf(BusinessException.class)
+ .extracting("code")
+ .isEqualTo(400);
+ }
+
+ @Test
+ void getCloudInstanceShouldMapEndpointsTest() {
+ GetInstanceResponse response = GetInstanceResponse.create().toBuilder()
+ .statusCode(200)
+ .body(GetInstanceResponseBody.builder()
+ .data(GetInstanceResponseBody.Data.builder()
+ .instanceId("rmq-cn-001")
+ .instanceName("prod")
+ .status("RUNNING")
+ .regionId(REGION)
+ .remark("prod instance")
+
.networkInfo(GetInstanceResponseBody.NetworkInfo.builder()
+ .endpoints(List.of(
+
GetInstanceResponseBody.Endpoints.builder()
+
.endpointType("TCP_INTERNET")
+
.endpointUrl("rmq-cn-001.rmq.aliyuncs.com:8080")
+ .build(),
+
GetInstanceResponseBody.Endpoints.builder()
+
.endpointType("TCP_VPC")
+
.endpointUrl("rmq-cn-001-vpc.rmq.aliyuncs.com:8080")
+ .build()))
+ .build())
+ .build())
+ .build())
+ .build();
+ when(clientFactory.call(eq(CREDENTIAL_ID), eq(REGION),
any())).thenReturn(response);
+
+ CloudInstanceDetailVO detail = service.getCloudInstance(CREDENTIAL_ID,
REGION, "rmq-cn-001");
+
+ assertThat(detail.getInstanceId()).isEqualTo("rmq-cn-001");
+ assertThat(detail.getStatus()).isEqualTo("RUNNING");
+ assertThat(detail.getRemark()).isEqualTo("prod instance");
+ assertThat(detail.getEndpoints()).hasSize(2);
+
assertThat(detail.getEndpoints().get(0).getEndpointType()).isEqualTo("TCP_INTERNET");
+ assertThat(detail.getEndpoints().get(1).getEndpointUrl())
+ .isEqualTo("rmq-cn-001-vpc.rmq.aliyuncs.com:8080");
+ }
+
+ private static List<ListInstancesResponseBody.List> instanceRows(int
count, int idOffset) {
+ List<ListInstancesResponseBody.List> rows = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ rows.add(instanceRow(String.format("rmq-instance-%04d", idOffset +
i), "instance-" + i));
+ }
+ return rows;
+ }
+
+ private static ListInstancesResponseBody.List instanceRow(String
instanceId, String instanceName) {
+ return ListInstancesResponseBody.List.builder()
+ .instanceId(instanceId)
+ .instanceName(instanceName)
+ .status("RUNNING")
+ .regionId("cn-hangzhou")
+ .topicCount(3L)
+ .groupCount(2L)
+ .remark("remark-" + instanceId)
+ .build();
+ }
+
+ private static ListInstancesResponse
instancesResponse(List<ListInstancesResponseBody.List> rows) {
+ return ListInstancesResponse.create().toBuilder()
+ .statusCode(200)
+ .body(ListInstancesResponseBody.builder()
+ .data(ListInstancesResponseBody.Data.builder()
+ .list(rows)
+ .pageNumber(1L)
+ .pageSize((long) AliyunConverters.PAGE_SIZE)
+ .totalCount((long) rows.size())
+ .build())
+ .build())
+ .build();
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunClientFactoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunClientFactoryTest.java
new file mode 100644
index 00000000..766072a4
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunClientFactoryTest.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.provider.alibaba;
+
+import com.aliyun.sdk.gateway.pop.exception.PopServerException;
+import com.aliyun.sdk.service.rocketmq20220801.AsyncClient;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListRegionsRequest;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListRegionsResponse;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListRegionsResponseBody;
+import org.apache.rocketmq.studio.cloud.credential.CloudCredentialRepository;
+import org.apache.rocketmq.studio.cloud.credential.CloudCredentialVO;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AliyunClientFactoryTest {
+
+ private static final String CREDENTIAL_ID = "cred-1";
+ private static final String REGION = "cn-hangzhou";
+
+ @Mock
+ private CloudCredentialRepository credentialRepository;
+
+ @Mock
+ private AsyncClient asyncClient;
+
+ private AliyunClientFactory factory;
+
+ @BeforeEach
+ void setUp() {
+ factory = new AliyunClientFactory(credentialRepository);
+ }
+
+ @Test
+ void endpointShouldFollowRegionTest() {
+ assertThat(AliyunClientFactory.endpointFor("cn-hangzhou"))
+ .isEqualTo("rocketmq.cn-hangzhou.aliyuncs.com");
+ assertThat(AliyunClientFactory.endpointFor("ap-southeast-1"))
+ .isEqualTo("rocketmq.ap-southeast-1.aliyuncs.com");
+ }
+
+ @Test
+ void clientShouldCachePerCredentialAndRegionTest() {
+
when(credentialRepository.findById(CREDENTIAL_ID)).thenReturn(Optional.of(credential()));
+
+ AsyncClient first = factory.client(CREDENTIAL_ID, REGION);
+ AsyncClient second = factory.client(CREDENTIAL_ID, REGION);
+ AsyncClient otherRegion = factory.client(CREDENTIAL_ID, "cn-beijing");
+
+ assertThat(first).isSameAs(second);
+ assertThat(otherRegion).isNotSameAs(first);
+ factory.close();
+ }
+
+ @Test
+ void clientShouldThrow404WhenCredentialMissingTest() {
+
when(credentialRepository.findById("missing")).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> factory.client("missing", REGION))
+ .isInstanceOf(BusinessException.class)
+ .extracting("code")
+ .isEqualTo(404);
+ }
+
+ @Test
+ void callShouldThrow504OnTimeoutTest() {
+ factory.setCallTimeoutSeconds(1L);
+ AliyunClientFactory spy = Mockito.spy(factory);
+ doReturn(asyncClient).when(spy).client(anyString(), anyString());
+
+ assertThatThrownBy(() -> spy.call(CREDENTIAL_ID, REGION,
+ client -> new CompletableFuture<>()))
+ .isInstanceOf(BusinessException.class)
+ .extracting("code")
+ .isEqualTo(504);
+ }
+
+ @Test
+ void callShouldMapServer404ToBusinessExceptionTest() {
+ AliyunClientFactory spy = Mockito.spy(factory);
+ doReturn(asyncClient).when(spy).client(anyString(), anyString());
+ PopServerException error = new PopServerException("instance not
found");
+ error.setStatusCode(404);
+ error.setErrCode("Instance.NotFound");
+ when(asyncClient.listRegions(any(ListRegionsRequest.class)))
+ .thenReturn(CompletableFuture.failedFuture(error));
+
+ assertThatThrownBy(() -> spy.call(CREDENTIAL_ID, REGION, client ->
client.listRegions(
+ ListRegionsRequest.builder().build())))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(ex -> {
+ BusinessException business = (BusinessException) ex;
+ assertThat(business.getCode()).isEqualTo(404);
+ assertThat(business.getMessage()).contains("instance not
found");
+ });
+ }
+
+ @Test
+ void callShouldMapInvalidAccessKeyTo401Test() {
+ AliyunClientFactory spy = Mockito.spy(factory);
+ doReturn(asyncClient).when(spy).client(anyString(), anyString());
+ PopServerException error = new PopServerException("bad key");
+ error.setStatusCode(403);
+ error.setErrCode("InvalidAccessKeyId");
+ when(asyncClient.listRegions(any(ListRegionsRequest.class)))
+ .thenReturn(CompletableFuture.failedFuture(error));
+
+ assertThatThrownBy(() -> spy.call(CREDENTIAL_ID, REGION, client ->
client.listRegions(
+ ListRegionsRequest.builder().build())))
+ .isInstanceOf(BusinessException.class)
+ .extracting("code")
+ .isEqualTo(401);
+ }
+
+ @Test
+ void callShouldMapGenericFailureTo502Test() {
+ AliyunClientFactory spy = Mockito.spy(factory);
+ doReturn(asyncClient).when(spy).client(anyString(), anyString());
+ when(asyncClient.listRegions(any(ListRegionsRequest.class)))
+ .thenReturn(CompletableFuture.failedFuture(new
IllegalStateException("boom")));
+
+ assertThatThrownBy(() -> spy.call(CREDENTIAL_ID, REGION, client ->
client.listRegions(
+ ListRegionsRequest.builder().build())))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(ex -> {
+ BusinessException business = (BusinessException) ex;
+ assertThat(business.getCode()).isEqualTo(502);
+ assertThat(business.getMessage()).contains("boom");
+ });
+ }
+
+ @Test
+ void callShouldReturnSuccessfulBodyTest() {
+ AliyunClientFactory spy = Mockito.spy(factory);
+ doReturn(asyncClient).when(spy).client(anyString(), anyString());
+ ListRegionsResponse response = ListRegionsResponse.create().toBuilder()
+ .statusCode(200)
+ .body(ListRegionsResponseBody.builder().success(true).build())
+ .build();
+ when(asyncClient.listRegions(any(ListRegionsRequest.class)))
+ .thenReturn(CompletableFuture.completedFuture(response));
+
+ ListRegionsResponse result = spy.call(CREDENTIAL_ID, REGION,
+ client ->
client.listRegions(ListRegionsRequest.builder().build()));
+
+ assertThat(result.getBody().getSuccess()).isTrue();
+ }
+
+ private CloudCredentialVO credential() {
+ CloudCredentialVO credential = new CloudCredentialVO();
+ credential.setId(CREDENTIAL_ID);
+ credential.setName("unit-test");
+ credential.setVendor(InstanceVendor.ALIYUN);
+ credential.setAccessKey("LTAI5tUnitTestKey000000001");
+ credential.setSecretKey("UnitTestSecret000000000000000001");
+ return credential;
+ }
+}
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
new file mode 100644
index 00000000..330f1bcd
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProviderTest.java
@@ -0,0 +1,440 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.provider.alibaba;
+
+import com.aliyun.sdk.service.rocketmq20220801.AsyncClient;
+import
com.aliyun.sdk.service.rocketmq20220801.models.CreateConsumerGroupRequest;
+import
com.aliyun.sdk.service.rocketmq20220801.models.CreateConsumerGroupResponse;
+import
com.aliyun.sdk.service.rocketmq20220801.models.CreateConsumerGroupResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.DataTopicLagMapValue;
+import
com.aliyun.sdk.service.rocketmq20220801.models.GetConsumerGroupLagResponse;
+import
com.aliyun.sdk.service.rocketmq20220801.models.GetConsumerGroupLagResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.GetTraceResponse;
+import com.aliyun.sdk.service.rocketmq20220801.models.GetTraceResponseBody;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListConsumerGroupsResponse;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ListConsumerGroupsResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListMessagesResponse;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListMessagesResponseBody;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListTopicsRequest;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListTopicsResponse;
+import com.aliyun.sdk.service.rocketmq20220801.models.ListTopicsResponseBody;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ResetConsumeOffsetRequest;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ResetConsumeOffsetResponse;
+import
com.aliyun.sdk.service.rocketmq20220801.models.ResetConsumeOffsetResponseBody;
+import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.common.domain.enums.TopicType;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.instance.InstanceRepository;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
+import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
+import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
+import org.apache.rocketmq.studio.instance.message.TraceNodeVO;
+import org.apache.rocketmq.studio.instance.message.TraceRecordVO;
+import org.apache.rocketmq.studio.instance.topic.TopicVO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AliyunInstanceProviderTest {
+
+ private static final String STUDIO_INSTANCE_ID = "inst-1";
+ private static final String CLOUD_INSTANCE_ID = "rmq-cn-001";
+ private static final String REGION = "cn-hangzhou";
+ private static final String CREDENTIAL_ID = "cred-1";
+
+ @Mock
+ private AliyunClientFactory clientFactory;
+
+ @Mock
+ private InstanceRepository instanceRepository;
+
+ @Mock
+ private AsyncClient asyncClient;
+
+ private AliyunInstanceProvider provider;
+
+ @BeforeEach
+ void setUp() {
+ provider = new AliyunInstanceProvider(clientFactory,
instanceRepository);
+ }
+
+ @Test
+ void listTopicsShouldMapMessageTypeAndFilterTest() {
+ stubInstance();
+ stubCallThrough();
+ ListTopicsResponse response = topicsResponse(
+ topicRow("topic-normal", "NORMAL"),
+ topicRow("topic-fifo", "FIFO"),
+ topicRow("topic-mystery", "MYSTERY"));
+ when(asyncClient.listTopics(any(ListTopicsRequest.class)))
+ .thenReturn(CompletableFuture.completedFuture(response));
+
+ List<TopicVO> all = provider.listTopics(STUDIO_INSTANCE_ID, null,
null);
+
+ assertThat(all).hasSize(3);
+ assertThat(all.get(0).getName()).isEqualTo("topic-normal");
+ assertThat(all.get(0).getType()).isEqualTo(TopicType.NORMAL);
+ assertThat(all.get(0).getInstanceId()).isEqualTo(STUDIO_INSTANCE_ID);
+ assertThat(all.get(0).getWriteQueues()).isZero();
+ assertThat(all.get(0).getReadQueues()).isZero();
+ assertThat(all.get(0).getRemark()).isEqualTo("remark-topic-normal");
+ assertThat(all.get(2).getType()).isNull();
+
+ List<TopicVO> fifos = provider.listTopics(STUDIO_INSTANCE_ID, "FIFO",
null);
+
+ assertThat(fifos).hasSize(1);
+ assertThat(fifos.get(0).getType()).isEqualTo(TopicType.FIFO);
+ }
+
+ @Test
+ void listConsumerGroupsShouldMapGroupIdTest() {
+ stubInstance();
+ stubCallThrough();
+ ListConsumerGroupsResponse response =
ListConsumerGroupsResponse.create().toBuilder()
+ .statusCode(200)
+ .body(ListConsumerGroupsResponseBody.builder()
+ .data(ListConsumerGroupsResponseBody.Data.builder()
+
.list(List.of(ListConsumerGroupsResponseBody.List.builder()
+ .consumerGroupId("GID_test")
+ .messageModel("Clustering")
+ .status("RUNNING")
+ .remark("test group")
+ .build()))
+ .pageNumber(1L)
+ .pageSize(100L)
+ .totalCount(1L)
+ .build())
+ .build())
+ .build();
+ when(asyncClient.listConsumerGroups(any()))
+ .thenReturn(CompletableFuture.completedFuture(response));
+
+ List<ConsumerGroupVO> groups =
provider.listConsumerGroups(STUDIO_INSTANCE_ID, null);
+
+ assertThat(groups).hasSize(1);
+ assertThat(groups.get(0).getName()).isEqualTo("GID_test");
+
assertThat(groups.get(0).getInstanceId()).isEqualTo(STUDIO_INSTANCE_ID);
+
assertThat(groups.get(0).getConsumeType()).isEqualTo(ConsumeType.CLUSTERING);
+ }
+
+ @Test
+ void getGroupProgressShouldMapLagRowsTest() {
+ stubInstance();
+ stubCallThrough();
+ GetConsumerGroupLagResponse response =
GetConsumerGroupLagResponse.create().toBuilder()
+ .statusCode(200)
+ .body(GetConsumerGroupLagResponseBody.builder()
+ .data(GetConsumerGroupLagResponseBody.Data.builder()
+ .consumerGroupId("GID_test")
+ .topicLagMap(Map.of("topic-a",
+
DataTopicLagMapValue.builder().readyCount(42L).build()))
+
.totalLag(GetConsumerGroupLagResponseBody.TotalLag.builder()
+ .readyCount(100L)
+ .build())
+ .build())
+ .build())
+ .build();
+ when(asyncClient.getConsumerGroupLag(any()))
+ .thenReturn(CompletableFuture.completedFuture(response));
+
+ List<QueueProgressVO> rows =
provider.getGroupProgress(STUDIO_INSTANCE_ID, "GID_test");
+
+ assertThat(rows).hasSize(2);
+ QueueProgressVO topicRow = rows.stream()
+ .filter(row -> "topic:topic-a".equals(row.getBroker()))
+ .findFirst()
+ .orElseThrow();
+ assertThat(topicRow.getDiffTotal()).isEqualTo(42L);
+ QueueProgressVO totalRow = rows.stream()
+ .filter(row -> "total".equals(row.getBroker()))
+ .findFirst()
+ .orElseThrow();
+ assertThat(totalRow.getDiffTotal()).isEqualTo(100L);
+ }
+
+ @Test
+ void queryMessagesShouldMapFieldsAndDecodeBase64BodyTest() {
+ stubInstance();
+ stubCallThrough();
+ String encodedBody =
Base64.getEncoder().encodeToString("hello".getBytes(StandardCharsets.UTF_8));
+ ListMessagesResponse response =
ListMessagesResponse.create().toBuilder()
+ .statusCode(200)
+ .body(ListMessagesResponseBody.builder()
+ .data(ListMessagesResponseBody.Data.builder()
+ .list(List.of(
+ ListMessagesResponseBody.List.builder()
+ .messageId("msg-1")
+ .topicName("topic-a")
+ .messageTag("tagA")
+ .messageKeys(List.of("k1",
"k2"))
+ .body(encodedBody)
+ .bodySize(5)
+ .bornHost("10.0.0.1")
+ .storeHost("10.0.0.2")
+ .storeTime("2023-03-22
12:17:08")
+ .userProperties(Map.of("a",
"b"))
+ .build(),
+ ListMessagesResponseBody.List.builder()
+ .messageId("msg-2")
+ .topicName("topic-a")
+ .messageTag("tagB")
+ .body("{}")
+ .bodySize(2)
+ .build()))
+ .pageNumber(1L)
+ .pageSize(20L)
+ .totalCount(2L)
+ .build())
+ .build())
+ .build();
+ when(asyncClient.listMessages(any()))
+ .thenReturn(CompletableFuture.completedFuture(response));
+
+ List<MessageRecordVO> records =
provider.queryMessages(STUDIO_INSTANCE_ID, "topic-a", null,
+ null, null, null, null);
+
+ assertThat(records).hasSize(2);
+ MessageRecordVO first = records.get(0);
+ assertThat(first.getMsgId()).isEqualTo("msg-1");
+ assertThat(first.getTag()).isEqualTo("tagA");
+ assertThat(first.getKey()).isEqualTo("k1 k2");
+ assertThat(first.getBody()).isEqualTo("hello");
+ assertThat(first.getBodyEncoding()).isEqualTo("UTF-8");
+ assertThat(first.getStoreTime())
+ .isEqualTo(AliyunConverters.parseTimeMillis("2023-03-22
12:17:08"));
+ assertThat(first.getBornHost()).isEqualTo("10.0.0.1");
+ assertThat(first.getProperties()).containsEntry("a", "b");
+ MessageRecordVO second = records.get(1);
+ assertThat(second.getBody()).isEqualTo("{}");
+ assertThat(second.getBodyEncoding()).isEqualTo("TEXT");
+
+ List<MessageRecordVO> filtered =
provider.queryMessages(STUDIO_INSTANCE_ID, "topic-a", null,
+ "tagB", null, null, null);
+
+ assertThat(filtered).hasSize(1);
+ assertThat(filtered.get(0).getMsgId()).isEqualTo("msg-2");
+ }
+
+ @Test
+ void createConsumerGroupShouldApplyDefaultsTest() {
+ stubInstance();
+ stubCallThrough();
+ when(asyncClient.createConsumerGroup(any()))
+
.thenReturn(CompletableFuture.completedFuture(CreateConsumerGroupResponse.create()
+ .toBuilder()
+ .statusCode(200)
+
.body(CreateConsumerGroupResponseBody.builder().data(true).build())
+ .build()));
+ ConsumerGroupVO group = new ConsumerGroupVO();
+ group.setName("GID_new");
+
+ ConsumerGroupVO created =
provider.createConsumerGroup(STUDIO_INSTANCE_ID, group);
+
+ ArgumentCaptor<CreateConsumerGroupRequest> captor =
+ ArgumentCaptor.forClass(CreateConsumerGroupRequest.class);
+ verify(asyncClient).createConsumerGroup(captor.capture());
+ CreateConsumerGroupRequest request = captor.getValue();
+ assertThat(request.getInstanceId()).isEqualTo(CLOUD_INSTANCE_ID);
+ assertThat(request.getConsumerGroupId()).isEqualTo("GID_new");
+ assertThat(request.getDeliveryOrderType()).isEqualTo("Concurrently");
+
assertThat(request.getConsumeRetryPolicy().getRetryPolicy()).isEqualTo("DefaultRetryPolicy");
+
assertThat(request.getConsumeRetryPolicy().getMaxRetryTimes()).isEqualTo(16);
+ assertThat(created.getInstanceId()).isEqualTo(STUDIO_INSTANCE_ID);
+ assertThat(created.getDeliveryOrderType()).isEqualTo("Concurrently");
+ assertThat(created.getRetryMaxTimes()).isEqualTo(16);
+ }
+
+ @Test
+ void resetOffsetShouldUseSpecifiedTimeTest() {
+ stubInstance();
+ stubCallThrough();
+ when(asyncClient.resetConsumeOffset(any()))
+
.thenReturn(CompletableFuture.completedFuture(ResetConsumeOffsetResponse.create()
+ .toBuilder()
+ .statusCode(200)
+
.body(ResetConsumeOffsetResponseBody.builder().success(true).build())
+ .build()));
+ long timestamp = 1679458628000L;
+
+ provider.resetOffset(STUDIO_INSTANCE_ID, "GID_test", timestamp,
"topic-a");
+
+ ArgumentCaptor<ResetConsumeOffsetRequest> captor =
+ ArgumentCaptor.forClass(ResetConsumeOffsetRequest.class);
+ verify(asyncClient).resetConsumeOffset(captor.capture());
+ ResetConsumeOffsetRequest request = captor.getValue();
+ assertThat(request.getConsumerGroupId()).isEqualTo("GID_test");
+ assertThat(request.getTopicName()).isEqualTo("topic-a");
+ assertThat(request.getResetType()).isEqualTo("SPECIFIED_TIME");
+
assertThat(request.getResetTime()).isEqualTo(AliyunConverters.formatTimeMillis(timestamp));
+ }
+
+ @Test
+ void getMessageTraceShouldMapNodesTest() {
+ stubInstance();
+ stubCallThrough();
+ GetTraceResponse response = GetTraceResponse.create().toBuilder()
+ .statusCode(200)
+ .body(GetTraceResponseBody.builder()
+ .data(GetTraceResponseBody.Data.builder()
+
.producerInfo(GetTraceResponseBody.ProducerInfo.builder()
+
.records(List.of(GetTraceResponseBody.ProducerInfoRecords.builder()
+ .produceTime("2023-03-22
12:17:08")
+ .produceStatus("SEND_OK")
+ .produceDuration(12L)
+ .clientHost("10.0.0.1")
+ .messageSource("SDK")
+ .build()))
+ .build())
+
.brokerInfo(GetTraceResponseBody.BrokerInfo.builder()
+
.operations(List.of(GetTraceResponseBody.Operations.builder()
+ .operateType("store")
+ .operateTime("2023-03-22
12:17:09")
+ .build()))
+ .build())
+
.consumerInfos(List.of(GetTraceResponseBody.ConsumerInfos.builder()
+ .consumerGroupId("GID_test")
+
.records(List.of(GetTraceResponseBody.Records.builder()
+ .consumeStatus("CONSUME_OK")
+ .clientHost("10.0.0.3")
+ .operations(List.of(
+
GetTraceResponseBody.RecordsOperations.builder()
+
.operateType("pull")
+
.operateTime("2023-03-22 12:17:10")
+ .build()))
+ .build()))
+ .build()))
+ .build())
+ .build())
+ .build();
+
when(asyncClient.getTrace(any())).thenReturn(CompletableFuture.completedFuture(response));
+
+ TraceRecordVO trace = provider.getMessageTrace(STUDIO_INSTANCE_ID,
"msg-1");
+
+ assertThat(trace.getNodes()).hasSize(3);
+ TraceNodeVO producer = trace.getNodes().get(0);
+ assertThat(producer.getTitle()).isEqualTo("Producer");
+ assertThat(producer.getStatus()).isEqualTo("SEND_OK");
+ assertThat(producer.getCostTime()).isEqualTo(12L);
+ assertThat(producer.getTimestamp())
+ .isEqualTo(AliyunConverters.parseTimeMillis("2023-03-22
12:17:08"));
+ assertThat(trace.getNodes().get(1).getTitle()).isEqualTo("Broker
store");
+ TraceNodeVO consumer = trace.getNodes().get(2);
+ assertThat(consumer.getTitle()).isEqualTo("Consumer GID_test");
+ assertThat(consumer.getStatus()).isEqualTo("CONSUME_OK");
+ }
+
+ @Test
+ void mappedBusinessExceptionShouldPropagateTest() {
+ stubInstance();
+ when(clientFactory.call(anyString(), anyString(), any()))
+ .thenThrow(new BusinessException(404, "Aliyun resource not
found"));
+
+ assertThatThrownBy(() -> provider.listTopics(STUDIO_INSTANCE_ID, null,
null))
+ .isInstanceOf(BusinessException.class)
+ .extracting("code")
+ .isEqualTo(404);
+ }
+
+ @Test
+ void resolveShouldRejectMissingCloudBindingTest() {
+ InstanceVO instance = InstanceVO.builder()
+ .name("incomplete")
+ .vendor(InstanceVendor.ALIYUN)
+ .cloudInstanceId(CLOUD_INSTANCE_ID)
+ .regionId(REGION)
+ .build();
+
when(instanceRepository.findById(STUDIO_INSTANCE_ID)).thenReturn(Optional.of(instance));
+
+ assertThatThrownBy(() -> provider.listTopics(STUDIO_INSTANCE_ID, null,
null))
+ .isInstanceOf(BusinessException.class)
+ .extracting("code")
+ .isEqualTo(400);
+ }
+
+ private void stubInstance() {
+ InstanceVO instance = InstanceVO.builder()
+ .name("aliyun-prod")
+ .vendor(InstanceVendor.ALIYUN)
+ .cloudInstanceId(CLOUD_INSTANCE_ID)
+ .regionId(REGION)
+ .credentialId(CREDENTIAL_ID)
+ .build();
+
when(instanceRepository.findById(STUDIO_INSTANCE_ID)).thenReturn(Optional.of(instance));
+ }
+
+ private void stubCallThrough() {
+ when(clientFactory.call(anyString(), anyString(),
any())).thenAnswer(invocation -> {
+ Function<AsyncClient, CompletableFuture<Object>> action =
invocation.getArgument(2);
+ return action.apply(asyncClient).join();
+ });
+ }
+
+ private static ListTopicsResponse
topicsResponse(ListTopicsResponseBody.List... rows) {
+ return ListTopicsResponse.create().toBuilder()
+ .statusCode(200)
+ .body(ListTopicsResponseBody.builder()
+ .data(ListTopicsResponseBody.Data.builder()
+ .list(List.of(rows))
+ .pageNumber(1L)
+ .pageSize(100L)
+ .totalCount((long) rows.length)
+ .build())
+ .build())
+ .build();
+ }
+
+ private static ListTopicsResponseBody.List topicRow(String name, String
messageType) {
+ return ListTopicsResponseBody.List.builder()
+ .topicName(name)
+ .messageType(messageType)
+ .remark("remark-" + name)
+ .build();
+ }
+
+ @Test
+ void normalizeDeliveryOrderTypeShouldMapFifoToOrderlyTest() {
+ org.junit.jupiter.api.Assertions.assertEquals("Orderly",
+ AliyunInstanceProvider.normalizeDeliveryOrderType("FIFO"));
+ org.junit.jupiter.api.Assertions.assertEquals("Orderly",
+ AliyunInstanceProvider.normalizeDeliveryOrderType("orderly"));
+ org.junit.jupiter.api.Assertions.assertEquals("Concurrently",
+ AliyunInstanceProvider.normalizeDeliveryOrderType(null));
+ org.junit.jupiter.api.Assertions.assertEquals("Concurrently",
+
AliyunInstanceProvider.normalizeDeliveryOrderType("Concurrently"));
+ }
+}