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 c1f9b2e3e fix(alert): require numeric zero in DingTalk responses
(#6924)
c1f9b2e3e is described below
commit c1f9b2e3eb4479936efd12ffac9b17f625b94d06
Author: pilichoumao <[email protected]>
AuthorDate: Mon Oct 26 16:32:53 2026 +0800
fix(alert): require numeric zero in DingTalk responses (#6924)
* fix(alert): require numeric zero in DingTalk responses
---
docs/studio-native-alerting-design.md | 2 +-
.../ops/alert/NotificationOutboxService.java | 7 +-
.../alert/NotificationDingTalkResponseTest.java | 136 +++++++++++++++++++++
3 files changed, 142 insertions(+), 3 deletions(-)
diff --git a/docs/studio-native-alerting-design.md
b/docs/studio-native-alerting-design.md
index 27648f72d..4e4831776 100644
--- a/docs/studio-native-alerting-design.md
+++ b/docs/studio-native-alerting-design.md
@@ -256,7 +256,7 @@ Only the active lease holder collects and evaluates.
Notification outbox rows us
## Notifications and Silences
-The supported notification channels are DingTalk, the SMS webhook configured
in General Settings, and Email. A real event creates outbox rows for enabled
supported channels. Independent notification-channel and notification-policy
CRUD are future work.
+The supported notification channels are DingTalk, the SMS webhook configured
in General Settings, and Email. DingTalk delivery requires an HTTP success
response with a numeric zero `errcode`; nonzero values, including fractions,
are rejected. A real event creates outbox rows for enabled supported channels.
Independent notification-channel and notification-policy CRUD are future work.
The dispatcher claims a row for one minute by default and renews the claim
every 20 seconds while the external
delivery is running. Deployments with slower receivers can tune
`studio.alerting.notification-claim-timeout` and
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
index 1e754daaf..d78b26f9d 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
@@ -8,6 +8,7 @@ package org.apache.rocketmq.studio.ops.alert;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
@@ -459,8 +460,10 @@ public class NotificationOutboxService {
private static void validateDingTalkResponse(String response) {
try {
- JsonNode body = JSON.readTree(response);
- if (body != null && body.path("errcode").canConvertToInt() &&
body.path("errcode").asInt() == 0) {
+ // Keep nonzero fractions intact: integer conversion truncates
them, and
+ // double parsing can underflow a small nonzero error code to zero.
+ JsonNode body =
JSON.reader().with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(response);
+ if (body != null && body.path("errcode").isNumber() &&
body.path("errcode").decimalValue().signum() == 0) {
return;
}
String error = body == null ? null :
body.path("errmsg").asText(null);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationDingTalkResponseTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationDingTalkResponseTest.java
new file mode 100644
index 000000000..53bd4a80e
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationDingTalkResponseTest.java
@@ -0,0 +1,136 @@
+/*
+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.
+*/
+package org.apache.rocketmq.studio.ops.alert;
+
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.stream.Stream;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.cluster.metrics.AlertingProperties;
+import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
+import
org.apache.rocketmq.studio.persistence.entity.RmqAlertNotificationOutbox;
+import
org.apache.rocketmq.studio.persistence.mapper.RmqAlertNotificationOutboxMapper;
+import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
+import org.apache.rocketmq.studio.settings.SettingsRepository;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentCaptor;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestTemplate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static
org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+class NotificationDingTalkResponseTest {
+ private final RmqAlertNotificationOutboxMapper mapper =
mock(RmqAlertNotificationOutboxMapper.class);
+ private final SettingsRepository settings = mock(SettingsRepository.class);
+ private final AlertRepository alerts = mock(AlertRepository.class);
+ private NotificationOutboxService service;
+ private MockRestServiceServer server;
+
+ @BeforeEach
+ void setUpTest() {
+ RestTemplate client = new RestTemplate();
+ server = MockRestServiceServer.bindTo(client).build();
+
when(settings.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+ .dingtalkWebhook("https://example.com/hook").build());
+ service = new NotificationOutboxService(mapper, settings,
mock(AlertSilenceService.class), alerts,
+ mock(OperationAuditService.class), client, () -> null,
+ new AlertingProperties(),
mock(ScheduledExecutorService.class));
+ }
+
+ @AfterEach
+ void closeTest() {
+ service.closeHeartbeatExecutor();
+ }
+
+ static Stream<Arguments> responses() {
+ return Stream.of(
+ Arguments.of("{\"errcode\":0}", true),
+ Arguments.of("{\"errcode\":0.0}", true),
+ Arguments.of("{\"errcode\":-0.0}", true),
+ Arguments.of("{\"errcode\":0e-400}", true),
+ Arguments.of("{\"errcode\":0.5}", false),
+ Arguments.of("{\"errcode\":-0.5}", false),
+ Arguments.of("{\"errcode\":1e-400}", false),
+ Arguments.of("{\"errcode\":-1e-400}", false),
+ Arguments.of("{\"errcode\":310000,\"errmsg\":\"rejected\"}",
false),
+ Arguments.of("{\"errcode\":2147483648}", false),
+ Arguments.of("{}", false),
+ Arguments.of("{\"errcode\":null}", false),
+ Arguments.of("{\"errcode\":\"0\"}", false),
+ Arguments.of("accepted", false));
+ }
+
+ @ParameterizedTest
+ @MethodSource("responses")
+ void testNotificationRequiresANumericZeroResponseTest(String response,
boolean accepted) {
+ respondWith(response);
+
+ if (accepted) {
+ assertThatCode(() ->
service.sendTestMessage("dingtalk")).doesNotThrowAnyException();
+ } else {
+ assertThatThrownBy(() -> service.sendTestMessage("dingtalk"))
+
.isInstanceOf(IllegalStateException.class).hasMessageContaining("Test
notification failed:");
+ }
+
+ server.verify();
+ }
+
+ @ParameterizedTest
+ @MethodSource("responses")
+ void queuedDeliveryRequiresANumericZeroResponseTest(String response,
boolean accepted) {
+ RmqAlertNotificationOutbox row = new RmqAlertNotificationOutbox();
+ row.setId(8L);
+ row.setAlertId(9L);
+ row.setChannel("dingtalk");
+ row.setStatus("PENDING");
+ row.setAttemptCount(0);
+ when(mapper.findDispatchable(any(LocalDateTime.class),
any(LocalDateTime.class), any(Integer.class)))
+ .thenReturn(List.of(row));
+ when(mapper.claimForDispatch(any(), any(LocalDateTime.class),
any(LocalDateTime.class),
+ any(LocalDateTime.class), anyString(),
anyInt())).thenReturn(1);
+ when(mapper.update(any(), any())).thenReturn(1);
+
when(alerts.findAlertById(9L)).thenReturn(Optional.of(SystemAlertVO.builder().id(9L)
+
.level(AlertLevel.warning).title("Lag").description("high").instanceId("local").build()));
+ respondWith(response);
+
+ service.dispatch();
+
+ server.verify();
+ assertStatus(accepted ? "DELIVERED" : "RETRY_WAIT");
+ }
+
+ private void respondWith(String response) {
+ server.expect(requestTo("https://example.com/hook"))
+ .andRespond(withSuccess(response, MediaType.APPLICATION_JSON));
+ }
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private void assertStatus(String expected) {
+ ArgumentCaptor<UpdateWrapper> update =
ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(mapper).update(any(), update.capture());
+ update.getValue().getSqlSet();
+
assertThat(update.getValue().getParamNameValuePairs().values()).contains(expected);
+ }
+}