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);
+    }
+}

Reply via email to