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 eafbb195 fix: harden ops request validation (#632)
eafbb195 is described below

commit eafbb195b45a771e43ad5124641c4af3a465bc94
Author: aias00 <[email protected]>
AuthorDate: Tue Jul 28 06:04:21 2026 -0700

    fix: harden ops request validation (#632)
    
    * [Studio] Validate ops request inputs
    
    * fix: refresh alert rule threshold unit
    
    * Handle missing request parameters
---
 .../common/exception/GlobalExceptionHandler.java   |   8 +
 .../apache/rocketmq/studio/ops/OpsController.java  |  13 +-
 .../rocketmq/studio/ops/OpsNameServerDTO.java      |   2 +
 .../org/apache/rocketmq/studio/ops/OpsTlsDTO.java  |   4 +-
 .../rocketmq/studio/ops/OpsVipChannelDTO.java      |   4 +-
 .../AcknowledgeSystemAlertDTO.java}                |  15 +-
 .../studio/ops/alert/AlertRuleController.java      |  12 +-
 .../DeleteAlertRuleDTO.java}                       |  15 +-
 .../studio/ops/alert/SystemAlertController.java    |   5 +-
 .../ToggleAlertRuleDTO.java}                       |  19 ++-
 .../AuditCleanupDTO.java}                          |  15 +-
 .../rocketmq/studio/ops/audit/AuditController.java |   5 +-
 .../exception/GlobalExceptionHandlerTest.java      |  14 ++
 .../rocketmq/studio/ops/OpsControllerTest.java     |  49 ++++++
 .../studio/ops/alert/AlertRuleControllerTest.java  | 173 +++++++++++++++++++++
 .../ops/alert/SystemAlertControllerTest.java       | 129 +++++++++++++++
 .../studio/ops/audit/AuditControllerTest.java      | 160 +++++++++++++++++++
 .../src/pages/ops/__tests__/alerts.test.ts         |  30 +++-
 .../src/pages/ops/alertRulePayload.ts              |  20 ++-
 web/src/pages/ops/alerts.tsx                       |  17 +-
 20 files changed, 649 insertions(+), 60 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
index 473f45ce..05bd3a24 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
@@ -24,6 +24,7 @@ import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.http.converter.HttpMessageNotReadableException;
 import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.MissingServletRequestParameterException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -64,6 +65,13 @@ public class GlobalExceptionHandler {
         return Result.error(HttpStatus.BAD_REQUEST.value(), "Invalid request 
body");
     }
 
+    @ExceptionHandler(MissingServletRequestParameterException.class)
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    public Result<?> 
handleMissingServletRequestParameterException(MissingServletRequestParameterException
 ex) {
+        String message = ex.getParameterName() + " is required";
+        return Result.error(HttpStatus.BAD_REQUEST.value(), message);
+    }
+
     @ExceptionHandler(Exception.class)
     @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
     public Result<?> handleException(Exception ex) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsController.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/OpsController.java
index 808aed7f..f65901f4 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsController.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/OpsController.java
@@ -18,6 +18,7 @@
 package org.apache.rocketmq.studio.ops;
 
 import org.apache.rocketmq.studio.common.domain.Result;
+import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -38,26 +39,26 @@ public class OpsController {
     }
 
     @PostMapping("/updateNameSvrAddr")
-    public Result<Void> updateNameSvrAddr(@RequestBody OpsNameServerDTO 
request) {
+    public Result<Void> updateNameSvrAddr(@Valid @RequestBody OpsNameServerDTO 
request) {
         opsService.updateNameServer(request.getNamesrvAddr());
         return Result.ok();
     }
 
     @PostMapping("/addNameSvrAddr")
-    public Result<Void> addNameSvrAddr(@RequestBody OpsNameServerDTO request) {
+    public Result<Void> addNameSvrAddr(@Valid @RequestBody OpsNameServerDTO 
request) {
         opsService.addNameServer(request.getNamesrvAddr());
         return Result.ok();
     }
 
     @PostMapping("/updateIsVIPChannel")
-    public Result<Void> updateIsVIPChannel(@RequestBody OpsVipChannelDTO 
request) {
-        opsService.updateVipChannel(request.isUseVIPChannel());
+    public Result<Void> updateIsVIPChannel(@Valid @RequestBody 
OpsVipChannelDTO request) {
+        opsService.updateVipChannel(request.getUseVIPChannel());
         return Result.ok();
     }
 
     @PostMapping("/updateUseTLS")
-    public Result<Void> updateUseTLS(@RequestBody OpsTlsDTO request) {
-        opsService.updateUseTLS(request.isUseTLS());
+    public Result<Void> updateUseTLS(@Valid @RequestBody OpsTlsDTO request) {
+        opsService.updateUseTLS(request.getUseTLS());
         return Result.ok();
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsNameServerDTO.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/OpsNameServerDTO.java
index 91640e2a..bd104d00 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsNameServerDTO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/OpsNameServerDTO.java
@@ -17,9 +17,11 @@
 
 package org.apache.rocketmq.studio.ops;
 
+import jakarta.validation.constraints.NotBlank;
 import lombok.Data;
 
 @Data
 public class OpsNameServerDTO {
+    @NotBlank(message = "namesrvAddr is required")
     private String namesrvAddr;
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsTlsDTO.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/OpsTlsDTO.java
index abf1526d..4c0fcba3 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsTlsDTO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/OpsTlsDTO.java
@@ -17,9 +17,11 @@
 
 package org.apache.rocketmq.studio.ops;
 
+import jakarta.validation.constraints.NotNull;
 import lombok.Data;
 
 @Data
 public class OpsTlsDTO {
-    private boolean useTLS;
+    @NotNull(message = "useTLS is required")
+    private Boolean useTLS;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
index 571597b0..d051c0cf 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
@@ -17,9 +17,11 @@
 
 package org.apache.rocketmq.studio.ops;
 
+import jakarta.validation.constraints.NotNull;
 import lombok.Data;
 
 @Data
 public class OpsVipChannelDTO {
-    private boolean useVIPChannel;
+    @NotNull(message = "useVIPChannel is required")
+    private Boolean useVIPChannel;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AcknowledgeSystemAlertDTO.java
similarity index 71%
copy from 
server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/ops/alert/AcknowledgeSystemAlertDTO.java
index 571597b0..74ff40bc 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AcknowledgeSystemAlertDTO.java
@@ -14,12 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package org.apache.rocketmq.studio.ops.alert;
 
-package org.apache.rocketmq.studio.ops;
-
+import jakarta.validation.constraints.NotBlank;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
 import lombok.Data;
+import lombok.NoArgsConstructor;
 
 @Data
-public class OpsVipChannelDTO {
-    private boolean useVIPChannel;
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AcknowledgeSystemAlertDTO {
+    @NotBlank(message = "id is required")
+    private String id;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
index 19677e5f..25a5e9ab 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.studio.ops.alert;
 
 import org.apache.rocketmq.studio.common.domain.Result;
+import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -25,7 +26,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
 import java.util.List;
-import java.util.Map;
 
 @RestController
 @RequestMapping("/api/alert-rules")
@@ -50,15 +50,13 @@ public class AlertRuleController {
     }
 
     @PostMapping("/toggle")
-    public Result<AlertRuleVO> toggleRule(@RequestBody Map<String, Object> 
request) {
-        String id = (String) request.get("id");
-        boolean enabled = (Boolean) request.get("enabled");
-        return Result.ok(alertService.toggleRule(id, enabled));
+    public Result<AlertRuleVO> toggleRule(@Valid @RequestBody 
ToggleAlertRuleDTO request) {
+        return Result.ok(alertService.toggleRule(request.getId(), 
request.getEnabled()));
     }
 
     @PostMapping("/delete")
-    public Result<Void> deleteRule(@RequestBody Map<String, String> request) {
-        alertService.deleteRule(request.get("id"));
+    public Result<Void> deleteRule(@Valid @RequestBody DeleteAlertRuleDTO 
request) {
+        alertService.deleteRule(request.getId());
         return Result.ok();
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/DeleteAlertRuleDTO.java
similarity index 71%
copy from 
server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/ops/alert/DeleteAlertRuleDTO.java
index 571597b0..0f820b4a 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/DeleteAlertRuleDTO.java
@@ -14,12 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package org.apache.rocketmq.studio.ops.alert;
 
-package org.apache.rocketmq.studio.ops;
-
+import jakarta.validation.constraints.NotBlank;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
 import lombok.Data;
+import lombok.NoArgsConstructor;
 
 @Data
-public class OpsVipChannelDTO {
-    private boolean useVIPChannel;
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DeleteAlertRuleDTO {
+    @NotBlank(message = "id is required")
+    private String id;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertController.java
index ab22fd08..7dda1bf9 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertController.java
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.studio.ops.alert;
 
 import org.apache.rocketmq.studio.common.domain.Result;
+import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -42,8 +43,8 @@ public class SystemAlertController {
     }
 
     @PostMapping("/acknowledge")
-    public Result<SystemAlertVO> acknowledgeAlert(@RequestBody Map<String, 
String> request) {
-        return Result.ok(alertService.acknowledgeAlert(request.get("id")));
+    public Result<SystemAlertVO> acknowledgeAlert(@Valid @RequestBody 
AcknowledgeSystemAlertDTO request) {
+        return Result.ok(alertService.acknowledgeAlert(request.getId()));
     }
 
     @PostMapping("/clear-acknowledged")
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/ToggleAlertRuleDTO.java
similarity index 64%
copy from 
server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/ops/alert/ToggleAlertRuleDTO.java
index 571597b0..24935e96 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/ToggleAlertRuleDTO.java
@@ -14,12 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package org.apache.rocketmq.studio.ops.alert;
 
-package org.apache.rocketmq.studio.ops;
-
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
 import lombok.Data;
+import lombok.NoArgsConstructor;
 
 @Data
-public class OpsVipChannelDTO {
-    private boolean useVIPChannel;
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ToggleAlertRuleDTO {
+    @NotBlank(message = "id is required")
+    private String id;
+
+    @NotNull(message = "enabled is required")
+    private Boolean enabled;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditCleanupDTO.java
similarity index 70%
copy from 
server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditCleanupDTO.java
index 571597b0..7c3fd411 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditCleanupDTO.java
@@ -14,12 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package org.apache.rocketmq.studio.ops.audit;
 
-package org.apache.rocketmq.studio.ops;
-
+import jakarta.validation.constraints.Positive;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
 import lombok.Data;
+import lombok.NoArgsConstructor;
 
 @Data
-public class OpsVipChannelDTO {
-    private boolean useVIPChannel;
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AuditCleanupDTO {
+    @Positive(message = "beforeDays must be greater than 0")
+    private Integer beforeDays;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
index 6f364f84..32d40492 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
@@ -18,6 +18,7 @@ package org.apache.rocketmq.studio.ops.audit;
 
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.domain.Result;
+import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -49,8 +50,8 @@ public class AuditController {
     }
 
     @PostMapping("/cleanup")
-    public Result<Map<String, Integer>> cleanupLogs(@RequestBody Map<String, 
Integer> request) {
-        int beforeDays = request.getOrDefault("beforeDays", 30);
+    public Result<Map<String, Integer>> cleanupLogs(@Valid 
@RequestBody(required = false) AuditCleanupDTO request) {
+        int beforeDays = request == null || request.getBeforeDays() == null ? 
30 : request.getBeforeDays();
         int deleted = auditService.cleanupLogs(beforeDays);
         return Result.ok(Map.of("deleted", deleted));
     }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
index 672d6d0b..c872f2d1 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
@@ -23,6 +23,7 @@ import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.test.web.servlet.setup.MockMvcBuilders;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
 import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -56,6 +57,14 @@ class GlobalExceptionHandlerTest {
                 .andExpect(jsonPath("$.message").value("failure-400"));
     }
 
+    @Test
+    void returnsBadRequestWhenRequiredRequestParamIsMissing() throws Exception 
{
+        mockMvc.perform(get("/test/required-param"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("key is required"));
+    }
+
     @RestController
     static class FailingController {
 
@@ -63,5 +72,10 @@ class GlobalExceptionHandlerTest {
         Result<Void> fail(@PathVariable int code) {
             throw new BusinessException(code, "failure-" + code);
         }
+
+        @GetMapping("/test/required-param")
+        Result<String> requiredParam(@RequestParam String key) {
+            return Result.ok(key);
+        }
     }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/OpsControllerTest.java 
b/server/src/test/java/org/apache/rocketmq/studio/ops/OpsControllerTest.java
index e65409e5..d0c4c304 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/ops/OpsControllerTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/OpsControllerTest.java
@@ -31,6 +31,7 @@ import java.util.Map;
 
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -80,6 +81,18 @@ class OpsControllerTest {
         verify(opsService).updateNameServer(eq("10.0.0.1:9876"));
     }
 
+    @Test
+    void updateNameSvrAddrShouldRejectMissingAddress() throws Exception {
+        mockMvc.perform(post("/api/ops/updateNameSvrAddr")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("namesrvAddr is 
required"));
+
+        verifyNoInteractions(opsService);
+    }
+
     @Test
     void addNameSvrAddrShouldDelegateToService() throws Exception {
         mockMvc.perform(post("/api/ops/addNameSvrAddr")
@@ -90,6 +103,18 @@ class OpsControllerTest {
         verify(opsService).addNameServer(eq("10.0.0.2:9876"));
     }
 
+    @Test
+    void addNameSvrAddrShouldRejectBlankAddress() throws Exception {
+        mockMvc.perform(post("/api/ops/addNameSvrAddr")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        
.content(objectMapper.writeValueAsString(Map.of("namesrvAddr", " "))))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("namesrvAddr is 
required"));
+
+        verifyNoInteractions(opsService);
+    }
+
     @Test
     void updateVipChannelShouldDelegateToService() throws Exception {
         mockMvc.perform(post("/api/ops/updateIsVIPChannel")
@@ -100,6 +125,18 @@ class OpsControllerTest {
         verify(opsService).updateVipChannel(false);
     }
 
+    @Test
+    void updateVipChannelShouldRejectMissingFlag() throws Exception {
+        mockMvc.perform(post("/api/ops/updateIsVIPChannel")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("useVIPChannel is 
required"));
+
+        verifyNoInteractions(opsService);
+    }
+
     @Test
     void updateUseTlsShouldDelegateToService() throws Exception {
         mockMvc.perform(post("/api/ops/updateUseTLS")
@@ -109,4 +146,16 @@ class OpsControllerTest {
 
         verify(opsService).updateUseTLS(true);
     }
+
+    @Test
+    void updateUseTlsShouldRejectMissingFlag() throws Exception {
+        mockMvc.perform(post("/api/ops/updateUseTLS")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("useTLS is required"));
+
+        verifyNoInteractions(opsService);
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
new file mode 100644
index 00000000..d8c3aeba
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
@@ -0,0 +1,173 @@
+/*
+ * 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.ops.alert;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import 
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(AlertRuleController.class)
+@AutoConfigureMockMvc(addFilters = false)
+class AlertRuleControllerTest {
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    @MockBean
+    private AlertService alertService;
+
+    @Test
+    void listRulesShouldReturnRules() throws Exception {
+        AlertRuleVO rule = AlertRuleVO.builder()
+                .id("rule-1")
+                .name("High Lag")
+                .metric("rocketmq_consumer_lag_messages")
+                .enabled(true)
+                .build();
+        when(alertService.listRules()).thenReturn(List.of(rule));
+
+        mockMvc.perform(get("/api/alert-rules"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].id").value("rule-1"))
+                .andExpect(jsonPath("$.data[0].enabled").value(true));
+    }
+
+    @Test
+    void createRuleShouldReturnCreatedRule() throws Exception {
+        AlertRuleVO request = AlertRuleVO.builder()
+                .name("High Lag")
+                .metric("rocketmq_consumer_lag_messages")
+                .enabled(true)
+                .build();
+        AlertRuleVO created = AlertRuleVO.builder()
+                .id("rule-1")
+                .name("High Lag")
+                .metric("rocketmq_consumer_lag_messages")
+                .enabled(true)
+                .build();
+        
when(alertService.createRule(any(AlertRuleVO.class))).thenReturn(created);
+
+        mockMvc.perform(post("/api/alert-rules/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(request)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.id").value("rule-1"))
+                .andExpect(jsonPath("$.data.name").value("High Lag"));
+    }
+
+    @Test
+    void toggleRuleShouldPassValidatedRequest() throws Exception {
+        AlertRuleVO toggled = AlertRuleVO.builder()
+                .id("rule-1")
+                .name("High Lag")
+                .enabled(false)
+                .build();
+        when(alertService.toggleRule("rule-1", false)).thenReturn(toggled);
+
+        mockMvc.perform(post("/api/alert-rules/toggle")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(Map.of("id", 
"rule-1", "enabled", false))))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.id").value("rule-1"))
+                .andExpect(jsonPath("$.data.enabled").value(false));
+
+        verify(alertService).toggleRule(eq("rule-1"), eq(false));
+    }
+
+    @Test
+    void toggleRuleShouldRejectMissingId() throws Exception {
+        mockMvc.perform(post("/api/alert-rules/toggle")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        
.content(objectMapper.writeValueAsString(Map.of("enabled", true))))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("id is required"));
+
+        verifyNoInteractions(alertService);
+    }
+
+    @Test
+    void toggleRuleShouldRejectMissingEnabled() throws Exception {
+        mockMvc.perform(post("/api/alert-rules/toggle")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(Map.of("id", 
"rule-1"))))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("enabled is required"));
+
+        verifyNoInteractions(alertService);
+    }
+
+    @Test
+    void toggleRuleShouldRejectInvalidEnabledType() throws Exception {
+        mockMvc.perform(post("/api/alert-rules/toggle")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(Map.of("id", 
"rule-1", "enabled", "invalid"))))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("Invalid request 
body"));
+
+        verifyNoInteractions(alertService);
+    }
+
+    @Test
+    void deleteRuleShouldPassValidatedRequest() throws Exception {
+        mockMvc.perform(post("/api/alert-rules/delete")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(Map.of("id", 
"rule-1"))))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.message").value("success"));
+
+        verify(alertService).deleteRule("rule-1");
+    }
+
+    @Test
+    void deleteRuleShouldRejectBlankId() throws Exception {
+        mockMvc.perform(post("/api/alert-rules/delete")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(Map.of("id", 
" "))))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("id is required"));
+
+        verifyNoInteractions(alertService);
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/SystemAlertControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/SystemAlertControllerTest.java
new file mode 100644
index 00000000..ca655a62
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/SystemAlertControllerTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.ops.alert;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import 
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(SystemAlertController.class)
+@AutoConfigureMockMvc(addFilters = false)
+class SystemAlertControllerTest {
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    @MockBean
+    private AlertService alertService;
+
+    @Test
+    void listAlertsShouldReturnSystemAlerts() throws Exception {
+        SystemAlertVO alert = SystemAlertVO.builder()
+                .id("alert-1")
+                .level(AlertLevel.error)
+                .title("Broker Down")
+                .acknowledged(false)
+                .build();
+        when(alertService.listAlerts("error")).thenReturn(List.of(alert));
+
+        mockMvc.perform(get("/api/system-alerts").param("level", "error"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].id").value("alert-1"))
+                .andExpect(jsonPath("$.data[0].level").value("error"))
+                .andExpect(jsonPath("$.data[0].acknowledged").value(false));
+
+        verify(alertService).listAlerts("error");
+    }
+
+    @Test
+    void acknowledgeAlertShouldPassValidatedRequest() throws Exception {
+        SystemAlertVO acknowledged = SystemAlertVO.builder()
+                .id("alert-1")
+                .level(AlertLevel.warning)
+                .title("High Lag")
+                .acknowledged(true)
+                .build();
+        
when(alertService.acknowledgeAlert("alert-1")).thenReturn(acknowledged);
+
+        mockMvc.perform(post("/api/system-alerts/acknowledge")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(Map.of("id", 
"alert-1"))))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.id").value("alert-1"))
+                .andExpect(jsonPath("$.data.acknowledged").value(true));
+
+        verify(alertService).acknowledgeAlert("alert-1");
+    }
+
+    @Test
+    void acknowledgeAlertShouldRejectBlankId() throws Exception {
+        mockMvc.perform(post("/api/system-alerts/acknowledge")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(Map.of("id", 
" "))))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("id is required"));
+
+        verifyNoInteractions(alertService);
+    }
+
+    @Test
+    void acknowledgeAlertShouldRejectMissingId() throws Exception {
+        mockMvc.perform(post("/api/system-alerts/acknowledge")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(Map.of())))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("id is required"));
+
+        verifyNoInteractions(alertService);
+    }
+
+    @Test
+    void clearAcknowledgedShouldReturnClearedCount() throws Exception {
+        when(alertService.clearAcknowledged()).thenReturn(3);
+
+        mockMvc.perform(post("/api/system-alerts/clear-acknowledged"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.cleared").value(3));
+
+        verify(alertService).clearAcknowledged();
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
new file mode 100644
index 00000000..b27dfe40
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
@@ -0,0 +1,160 @@
+/*
+ * 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.ops.audit;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import 
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(AuditController.class)
+@AutoConfigureMockMvc(addFilters = false)
+class AuditControllerTest {
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    @MockBean
+    private AuditService auditService;
+
+    @Test
+    void queryLogsShouldReturnPageResult() throws Exception {
+        AuditRecordVO record = AuditRecordVO.builder()
+                .operator("admin")
+                .operationType("DELETE")
+                .target("topic-a")
+                .result("SUCCESS")
+                .build();
+        when(auditService.queryLogs(eq(2), eq(10), eq("topic"), eq("DELETE"),
+                eq("2026-07-01"), eq("2026-07-24"), eq("SUCCESS")))
+                .thenReturn(PageResult.of(List.of(record), 1, 2, 10));
+
+        mockMvc.perform(get("/api/audit-logs")
+                        .param("page", "2")
+                        .param("pageSize", "10")
+                        .param("search", "topic")
+                        .param("operationType", "DELETE")
+                        .param("startDate", "2026-07-01")
+                        .param("endDate", "2026-07-24")
+                        .param("result", "SUCCESS"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.items[0].operator").value("admin"))
+                
.andExpect(jsonPath("$.data.items[0].operationType").value("DELETE"))
+                .andExpect(jsonPath("$.data.total").value(1));
+
+        verify(auditService).queryLogs(eq(2), eq(10), eq("topic"), 
eq("DELETE"),
+                eq("2026-07-01"), eq("2026-07-24"), eq("SUCCESS"));
+    }
+
+    @Test
+    void cleanupLogsShouldUseProvidedRetention() throws Exception {
+        when(auditService.cleanupLogs(90)).thenReturn(7);
+
+        mockMvc.perform(post("/api/audit-logs/cleanup")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        
.content(objectMapper.writeValueAsString(Map.of("beforeDays", 90))))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.deleted").value(7));
+
+        verify(auditService).cleanupLogs(90);
+    }
+
+    @Test
+    void cleanupLogsShouldDefaultRetentionWhenBodyIsEmpty() throws Exception {
+        when(auditService.cleanupLogs(30)).thenReturn(3);
+
+        mockMvc.perform(post("/api/audit-logs/cleanup"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.deleted").value(3));
+
+        verify(auditService).cleanupLogs(30);
+    }
+
+    @Test
+    void cleanupLogsShouldDefaultRetentionWhenBeforeDaysIsMissing() throws 
Exception {
+        when(auditService.cleanupLogs(30)).thenReturn(3);
+
+        mockMvc.perform(post("/api/audit-logs/cleanup")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.deleted").value(3));
+
+        verify(auditService).cleanupLogs(30);
+    }
+
+    @Test
+    void cleanupLogsShouldRejectNonPositiveRetention() throws Exception {
+        mockMvc.perform(post("/api/audit-logs/cleanup")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        
.content(objectMapper.writeValueAsString(Map.of("beforeDays", 0))))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("beforeDays must be 
greater than 0"));
+
+        verifyNoInteractions(auditService);
+    }
+
+    @Test
+    void cleanupLogsShouldRejectInvalidRetentionType() throws Exception {
+        mockMvc.perform(post("/api/audit-logs/cleanup")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        
.content(objectMapper.writeValueAsString(Map.of("beforeDays", "invalid"))))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("Invalid request 
body"));
+
+        verifyNoInteractions(auditService);
+    }
+
+    @Test
+    void queryLogsShouldUseDefaultPagination() throws Exception {
+        when(auditService.queryLogs(eq(1), eq(20), isNull(), isNull(), 
isNull(), isNull(), isNull()))
+                .thenReturn(PageResult.of(List.of(), 0, 1, 20));
+
+        mockMvc.perform(get("/api/audit-logs"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.items").isArray())
+                .andExpect(jsonPath("$.data.page").value(1))
+                .andExpect(jsonPath("$.data.size").value(20));
+
+        verify(auditService).queryLogs(eq(1), eq(20), isNull(), isNull(), 
isNull(), isNull(), isNull());
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java 
b/web/src/pages/ops/__tests__/alerts.test.ts
similarity index 53%
copy from 
server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
copy to web/src/pages/ops/__tests__/alerts.test.ts
index 571597b0..1da7b0e9 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
+++ b/web/src/pages/ops/__tests__/alerts.test.ts
@@ -15,11 +15,29 @@
  * limitations under the License.
  */
 
-package org.apache.rocketmq.studio.ops;
+import { describe, expect, it } from 'vitest';
+import { attachThresholdUnit } from '../alertRulePayload';
 
-import lombok.Data;
+describe('attachThresholdUnit', () => {
+  it('derives the threshold unit from the selected metric', () => {
+    expect(attachThresholdUnit({ metric: 'Broker 离线', threshold: 1 
})).toEqual({
+      metric: 'Broker 离线',
+      threshold: 1,
+      thresholdUnit: '个',
+    });
+  });
 
-@Data
-public class OpsVipChannelDTO {
-    private boolean useVIPChannel;
-}
+  it('overwrites stale units when a metric changes', () => {
+    expect(
+      attachThresholdUnit({
+        metric: '消费堆积量',
+        threshold: 100,
+        thresholdUnit: '%',
+      }),
+    ).toEqual({
+      metric: '消费堆积量',
+      threshold: 100,
+      thresholdUnit: '条',
+    });
+  });
+});
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java 
b/web/src/pages/ops/alertRulePayload.ts
similarity index 67%
copy from 
server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
copy to web/src/pages/ops/alertRulePayload.ts
index 571597b0..366fd182 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/OpsVipChannelDTO.java
+++ b/web/src/pages/ops/alertRulePayload.ts
@@ -15,11 +15,19 @@
  * limitations under the License.
  */
 
-package org.apache.rocketmq.studio.ops;
+export const thresholdUnits: Record<string, string> = {
+  磁盘使用率: '%',
+  消费堆积量: '条',
+  'TPS 异常': 'TPS',
+  'Broker 离线': '个',
+  'Proxy 连接数': '个',
+};
 
-import lombok.Data;
-
-@Data
-public class OpsVipChannelDTO {
-    private boolean useVIPChannel;
+export function attachThresholdUnit<T extends { metric: string }>(
+  values: T,
+): T & { thresholdUnit: string } {
+  return {
+    ...values,
+    thresholdUnit: thresholdUnits[values.metric] ?? '',
+  };
 }
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index 3f7265ae..6bcbf8ba 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -43,6 +43,7 @@ import {
   toggleAlertRule,
   updateAlertRule,
 } from '../../services/opsService';
+import { attachThresholdUnit } from './alertRulePayload';
 
 const { TextArea } = Input;
 
@@ -56,14 +57,6 @@ const metricOptions = ['磁盘使用率', '消费堆积量', 'TPS 异常', 'Brok
 
 const durationOptions = ['1分钟', '5分钟', '15分钟', '30分钟'];
 
-const thresholdUnits: Record<string, string> = {
-  磁盘使用率: '%',
-  消费堆积量: '条',
-  'TPS 异常': 'TPS',
-  'Broker 离线': '个',
-  'Proxy 连接数': '个',
-};
-
 const AlertsPage = () => {
   const { t } = useLang();
   const [rules, setRules] = useState<AlertRule[]>([]);
@@ -222,18 +215,16 @@ const AlertsPage = () => {
   const handleSubmit = async () => {
     try {
       const values = await form.validateFields();
+      const payload = attachThresholdUnit(values);
       setSubmitting(true);
       if (editingRule) {
-        const updated = await updateAlertRule({ ...editingRule, ...values });
+        const updated = await updateAlertRule({ ...editingRule, ...payload });
         setRules((previous) =>
           previous.map((rule) => (rule.id === editingRule.id ? updated : 
rule)),
         );
         message.success('告警规则已更新');
       } else {
-        const created = await createAlertRule({
-          ...values,
-          thresholdUnit: thresholdUnits[values.metric] ?? '',
-        });
+        const created = await createAlertRule(payload);
         setRules((previous) => [...previous, created]);
         message.success(t('alerts.ruleCreated'));
       }

Reply via email to