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 d584caddc fix: message id topology guard, proxy reload scope, webhook 
redaction and query budget (#2339)
d584caddc is described below

commit d584caddc76dd1bcf1e972dbf7a0ada5ab13dfd1
Author: aias00 <[email protected]>
AuthorDate: Wed Aug 19 14:53:17 2026 +0800

    fix: message id topology guard, proxy reload scope, webhook redaction and 
query budget (#2339)
    
    * Prevent forged offset msgIds from reaching arbitrary brokers
    
    * Restrict proxy reloads to discovered cluster endpoints
    
    * Align auth credential tests with authenticated user context
    
    Constraint: AuthInterceptor now determines admin access from 
getAuthenticatedUser() rather than isAdmin() alone
    Rejected: changing AuthInterceptor behavior | production code is already 
correct for the intended auth model
    Confidence: high
    Scope-risk: narrow
    Tested: mvn 
-Dtest=AuthCredentialAuthorizationIntegrationTest,AuthInterceptorTest test; git 
diff --check
    Signed-off-by: liuhy <[email protected]>
    
    * docs: clarify system authorization context
    
    * Protect reader-visible settings reads from leaking notification webhook 
secrets
    
    * fix: return conflict for concurrent Studio user creation
    
    Signed-off-by: liuhy <[email protected]>
    
    * fix: synchronize browser auth state after policy changes
    
    Signed-off-by: liuhy <[email protected]>
    
    * Keep multi-backend metrics tests isolated from host networking
    
    Constraint: The test must keep asserting per-backend query paths without 
mutating JVM-global proxy state or depending on host-specific non-loopback 
interfaces
    Rejected: Reusing a site-local bind address plus global ProxySelector 
override | still couples the test to host network topology and process-wide 
state
    Confidence: high
    Scope-risk: narrow
    Tested: mvn -Dtest=MultiBackendMetricsSourceTest test; mvn test; git diff 
--check
    Signed-off-by: liuhy <[email protected]>
    
    * Keep topic message queries on a bounded newest-first budget
    
    Constraint: Topic queries must stay within a fixed per-queue pull budget 
while still returning the newest 200 messages in the 7-day window
    Rejected: Drop the pull cap entirely | would reintroduce unbounded scans 
and DoS risk on high-volume topics
    Rejected: Keep forward scans with a hard failure | still breaks once a 
queue crosses 1000 pulls before reaching the newest data
    Directive: Preserve newest-first ordering and confine any truncation to 
older offsets outside the guarded tail budget
    Confidence: high
    Scope-risk: narrow
    Tested: export 
JAVA_HOME=/Users/aias/Library/Java/JavaVirtualMachines/openjdk-21.0.2/Contents/Home
 && export 
PATH="/Library/Java/JavaVirtualMachines/jdk-17.0.3+7/Contents/Home/bin:/Users/aias/.qoder/entry:/Users/aias/.nvm/versions/node/v22.23.1/bin:/Applications/Visual
 Studio 
Code.app/Contents/Resources/app/bin:/Library/Java/JavaVirtualMachines/jdk-17.0.3+7/Contents/Home/bin:/Users/aias/.local/share/hyperframes-python/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cry
 [...]
    Signed-off-by: liuhy <[email protected]>
    
    * test: align DLQ resend audit assertion with classified signature
    
    ---------
    
    Signed-off-by: liuhy <[email protected]>
---
 .../rocketmq/studio/auth/AuthInterceptor.java      |  12 +-
 .../apache/rocketmq/studio/auth/AuthService.java   |   6 +-
 .../studio/auth/AuthenticatedUserContext.java      |  20 ++-
 .../studio/cluster/broker/ClusterService.java      |  14 ++
 .../studio/cluster/proxy/ProxyAddressService.java  |  48 ++++---
 .../studio/cluster/proxy/ProxyController.java      |   7 +-
 .../provider/apache/RocketMQMessageProvider.java   | 119 ++++++++++++++--
 .../studio/settings/GeneralSettingsVO.java         |  12 +-
 .../rocketmq/studio/settings/SettingsService.java  |  22 ++-
 ...AuthCredentialAuthorizationIntegrationTest.java |  12 ++
 .../rocketmq/studio/auth/AuthInterceptorTest.java  |  20 +++
 .../studio/auth/AuthServiceDatabaseTest.java       |  13 ++
 .../studio/cluster/broker/ClusterServiceTest.java  |  19 +++
 .../metrics/MultiBackendMetricsSourceTest.java     | 126 +++++++---------
 .../cluster/proxy/ProxyAddressServiceTest.java     |  63 +++++++-
 .../studio/cluster/proxy/ProxyControllerTest.java  |  16 +--
 .../provider/apache/RocketMQDLQProviderTest.java   |   2 +
 .../apache/RocketMQMessageProviderTest.java        | 158 +++++++++++++++++++--
 .../studio/settings/SettingsControllerTest.java    |  36 +++++
 .../studio/settings/SettingsServiceTest.java       |  23 +++
 web/src/App.test.tsx                               |   1 +
 web/src/App.tsx                                    |   7 +-
 web/src/api/generalSettings.test.ts                |   7 +
 web/src/api/settings.ts                            |  17 ++-
 web/src/pages/studio/UserManagement.tsx            |  33 ++++-
 25 files changed, 664 insertions(+), 149 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
index c8468e6eb..3e8402f81 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
@@ -59,9 +59,15 @@ public class AuthInterceptor implements HandlerInterceptor {
             writeError(response, HttpStatus.UNAUTHORIZED, "Unauthorized");
             return false;
         }
-        authService.getAuthenticatedUser(authorization)
-                .ifPresent(user -> 
AuthenticatedUserContext.setUser(user.getUserId(), user.getUsername()));
-        if (requiresAdmin(request, requestPath(request)) && 
!authService.isAdmin(authorization)) {
+        var authenticatedUser = 
authService.getAuthenticatedUser(authorization).orElse(null);
+        if (authenticatedUser != null) {
+            AuthenticatedUserContext.setUser(
+                    authenticatedUser.getUserId(),
+                    authenticatedUser.getUsername(),
+                    authenticatedUser.isAdmin());
+        }
+        if (requiresAdmin(request, requestPath(request))
+                && (authenticatedUser == null || 
!authenticatedUser.isAdmin())) {
             writeError(response, HttpStatus.FORBIDDEN, "Admin permission 
required");
             return false;
         }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
index 9d6a07407..d5a67f4ba 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
@@ -176,7 +176,11 @@ public class AuthService {
         user.setAdmin(admin);
         user.setEnabled(true);
         user.setPasswordChangedAt(now());
-        userMapper.insert(user);
+        try {
+            userMapper.insert(user);
+        } catch (DuplicateKeyException exception) {
+            throw new BusinessException(409, "Username is already in use");
+        }
         return user;
     }
 
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthenticatedUserContext.java
 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthenticatedUserContext.java
index b9c09fd28..f2bfcb045 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthenticatedUserContext.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthenticatedUserContext.java
@@ -26,20 +26,30 @@ public final class AuthenticatedUserContext {
 
     private static final ThreadLocal<String> CURRENT_USERNAME = new 
ThreadLocal<>();
     private static final ThreadLocal<String> CURRENT_USER_ID = new 
ThreadLocal<>();
+    private static final ThreadLocal<Boolean> CURRENT_ADMIN = new 
ThreadLocal<>();
 
     private AuthenticatedUserContext() {
     }
 
-    public static void setUsername(String username) {
+    public static void setUser(String username, boolean admin) {
         if (username == null || username.isBlank()) {
             clear();
             return;
         }
         CURRENT_USERNAME.set(username);
+        CURRENT_ADMIN.set(admin);
+    }
+
+    public static void setUsername(String username) {
+        setUser(username, false);
     }
 
     public static void setUser(Long userId, String username) {
-        setUsername(username);
+        setUser(userId, username, false);
+    }
+
+    public static void setUser(Long userId, String username, boolean admin) {
+        setUser(username, admin);
         if (userId == null) {
             CURRENT_USER_ID.remove();
         } else {
@@ -56,8 +66,14 @@ public final class AuthenticatedUserContext {
         return username == null ? SYSTEM_ACTOR : username;
     }
 
+    public static boolean currentUserIsAdminOrSystem() {
+        Boolean admin = CURRENT_ADMIN.get();
+        return admin == null || admin;
+    }
+
     public static void clear() {
         CURRENT_USERNAME.remove();
         CURRENT_USER_ID.remove();
+        CURRENT_ADMIN.remove();
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterService.java
index 004da7f46..a527b24f6 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterService.java
@@ -28,6 +28,7 @@ import 
org.apache.rocketmq.studio.cluster.nameserver.NameserverRegistryVO;
 import org.apache.rocketmq.studio.cluster.nameserver.RestartNameServerDTO;
 import org.apache.rocketmq.studio.cluster.nameserver.UpdateNameServerDTO;
 import org.apache.rocketmq.studio.cluster.nameserver.UpgradeNameServerDTO;
+import org.apache.rocketmq.studio.cluster.proxy.ProxyVO;
 import org.apache.rocketmq.studio.cluster.proxy.RestartProxyDTO;
 
 import org.apache.rocketmq.studio.common.domain.enums.FlushDiskType;
@@ -148,6 +149,19 @@ public class ClusterService {
         throw new BusinessException(503, "Cluster details are unavailable: " + 
id);
     }
 
+    public List<ProxyVO> listProxies(String clusterId) {
+        ClusterVO cluster = resolveCluster(clusterId);
+        if (cluster.getProxies() == null || cluster.getProxies().isEmpty()) {
+            return List.of();
+        }
+        return List.copyOf(cluster.getProxies());
+    }
+
+    public void requireProxy(String clusterId, String addr) {
+        ClusterVO cluster = resolveCluster(clusterId);
+        requireProxy(cluster, addr);
+    }
+
     /**
      * Attach live broker configuration (read from the first reachable master 
broker via the
      * admin API) to a discovered cluster. Falls back to the persisted config, 
if any, when the
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
index 578d044b2..14a27f9cd 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
@@ -17,11 +17,9 @@
 
 package org.apache.rocketmq.studio.cluster.proxy;
 
-import 
org.apache.rocketmq.studio.common.util.NoRedirectClientHttpRequestFactory;
-
-
-
+import org.apache.rocketmq.studio.cluster.broker.ClusterService;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import 
org.apache.rocketmq.studio.common.util.NoRedirectClientHttpRequestFactory;
 import jakarta.annotation.PreDestroy;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -33,7 +31,6 @@ import org.springframework.web.client.ResourceAccessException;
 import org.springframework.web.client.RestTemplate;
 
 import java.time.Duration;
-
 import java.util.ArrayList;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -76,6 +73,7 @@ public class ProxyAddressService {
     /** Bounded pool for the I/O-bound TCP probes; probes queue beyond this 
share the budget. */
     private static final int PROBE_EXECUTOR_THREADS = 8;
 
+    private final ClusterService clusterService;
     private final Set<String> proxyAddrs = new 
LinkedHashSet<>(List.of("127.0.0.1:8081"));
     private String currentProxyAddr = "127.0.0.1:8081";
     private final RestTemplate restTemplate;
@@ -84,19 +82,33 @@ public class ProxyAddressService {
     private final long topologyTotalTimeoutMillis;
 
     @Autowired
-    public ProxyAddressService(ProxyHealthProbe healthProbe) {
-        this(healthProbe, defaultProbeExecutor(), 
TOPOLOGY_TOTAL_TIMEOUT_MILLIS);
+    public ProxyAddressService(ClusterService clusterService, ProxyHealthProbe 
healthProbe) {
+        this(clusterService, healthProbe, newRestTemplate(), 
defaultProbeExecutor(), TOPOLOGY_TOTAL_TIMEOUT_MILLIS);
+    }
+
+    ProxyAddressService(ClusterService clusterService, ProxyHealthProbe 
healthProbe, RestTemplate restTemplate) {
+        this(clusterService, healthProbe, restTemplate, 
defaultProbeExecutor(), TOPOLOGY_TOTAL_TIMEOUT_MILLIS);
+    }
+
+    ProxyAddressService(ClusterService clusterService, ProxyHealthProbe 
healthProbe,
+                        ExecutorService probeExecutor, long 
topologyTotalTimeoutMillis) {
+        this(clusterService, healthProbe, newRestTemplate(), probeExecutor, 
topologyTotalTimeoutMillis);
     }
 
-    ProxyAddressService(ProxyHealthProbe healthProbe, ExecutorService 
probeExecutor,
-                        long topologyTotalTimeoutMillis) {
+    ProxyAddressService(ClusterService clusterService, ProxyHealthProbe 
healthProbe, RestTemplate restTemplate,
+                        ExecutorService probeExecutor, long 
topologyTotalTimeoutMillis) {
+        this.clusterService = clusterService;
         this.healthProbe = healthProbe;
+        this.restTemplate = restTemplate;
         this.probeExecutor = probeExecutor;
         this.topologyTotalTimeoutMillis = topologyTotalTimeoutMillis;
+    }
+
+    private static RestTemplate newRestTemplate() {
         NoRedirectClientHttpRequestFactory factory = new 
NoRedirectClientHttpRequestFactory();
         factory.setConnectTimeout(Duration.ofSeconds(3));
         factory.setReadTimeout(Duration.ofSeconds(3));
-        this.restTemplate = new RestTemplate(factory);
+        return new RestTemplate(factory);
     }
 
     private static ExecutorService defaultProbeExecutor() {
@@ -258,13 +270,10 @@ public class ProxyAddressService {
      * POSTs to {@code http://<addr>/admin/reloadConfig}. Throws {@link 
BusinessException}
      * on transport or protocol failure so the caller receives a structured 
error response.
      */
-    public void reloadConfig(String addr) {
+    public void reloadConfig(String clusterId, String addr) {
+        String normalizedClusterId = normalizeClusterId(clusterId);
         String normalized = normalizeProxyAddr(addr, "addr");
-        synchronized (this) {
-            if (!proxyAddrs.contains(normalized)) {
-                throw new BusinessException(400, "addr is not a registered 
proxy address");
-            }
-        }
+        clusterService.requireProxy(normalizedClusterId, normalized);
         String url = "http://"; + normalized + RELOAD_PATH;
         try {
             ResponseEntity<String> response = restTemplate.postForEntity(url, 
null, String.class);
@@ -286,6 +295,13 @@ public class ProxyAddressService {
         }
     }
 
+    private String normalizeClusterId(String clusterId) {
+        if (clusterId == null || clusterId.trim().isEmpty()) {
+            throw new BusinessException(400, "clusterId is required");
+        }
+        return clusterId.trim();
+    }
+
     private String normalizeProxyAddr(String proxyAddr, String fieldName) {
         if (proxyAddr == null || proxyAddr.trim().isEmpty()) {
             throw new BusinessException(400, fieldName + " is required");
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyController.java
index 34cba7970..423ff8b30 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyController.java
@@ -42,10 +42,7 @@ public class ProxyController {
     @GetMapping
     public Result<List<ProxyVO>> listProxies(@RequestParam(required = false) 
String clusterId) {
         requireClusterId(clusterId);
-        List<ProxyVO> proxies = 
proxyAddressService.getHomePage().getProxyAddrList().stream()
-                .map(addr -> ProxyVO.builder().addr(addr).build())
-                .toList();
-        return Result.ok(proxies);
+        return Result.ok(clusterService.listProxies(clusterId));
     }
 
     @GetMapping("/topology")
@@ -55,7 +52,7 @@ public class ProxyController {
 
     @PostMapping("/config/reload")
     public Result<Map<String, Boolean>> reloadProxyConfig(@Valid @RequestBody 
RestartProxyDTO command) {
-        proxyAddressService.reloadConfig(command.getAddr());
+        proxyAddressService.reloadConfig(command.getClusterId(), 
command.getAddr());
         return Result.ok(Map.of("success", true));
     }
 
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
index cf9f43bd3..fc324929b 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
@@ -26,6 +26,8 @@ import org.apache.rocketmq.common.message.MessageExt;
 import org.apache.rocketmq.common.message.MessageId;
 import org.apache.rocketmq.common.message.MessageQueue;
 import org.apache.rocketmq.remoting.RPCHook;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
 import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.common.domain.enums.DeliveryStatus;
@@ -53,6 +55,7 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.Base64;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -75,6 +78,7 @@ public class RocketMQMessageProvider implements 
MessageProvider {
     private static final int TRACE_QUERY_MAX = 64;
     private static final int DEFAULT_TOPIC_LIMIT = 200;
     private static final int TOPIC_QUERY_HARD_CAP = 2000;
+    private static final int TOPIC_PULL_BATCH_SIZE = 32;
     private static final int MAX_BODY_DISPLAY_BYTES = 64 * 1024;
     private static final int MAX_BINARY_BODY_DISPLAY_BYTES = 48 * 1024;
     private static final int MAX_PROPERTIES = 64;
@@ -85,6 +89,8 @@ public class RocketMQMessageProvider implements 
MessageProvider {
     private static final long MAX_TOPIC_QUERY_WINDOW_MILLIS = 7 * 
ONE_DAY_MILLIS;
     private static final int MAX_PULLS_PER_QUEUE = 1_000;
     private static final int MAX_CONSECUTIVE_OFFSET_ILLEGAL = 3;
+    private static final int MAX_TOPIC_SCAN_MESSAGES_PER_QUEUE = 
MAX_PULLS_PER_QUEUE * TOPIC_PULL_BATCH_SIZE;
+    private static final int MAX_PULL_ATTEMPTS_PER_QUEUE = MAX_PULLS_PER_QUEUE 
+ MAX_CONSECUTIVE_OFFSET_ILLEGAL;
     private static final Comparator<MessageRecordVO> TOPIC_QUERY_ORDER = 
Comparator
             .comparingLong(MessageRecordVO::getStoreTime)
             .thenComparing(MessageRecordVO::getMsgId, 
Comparator.nullsFirst(String::compareTo));
@@ -155,12 +161,10 @@ public class RocketMQMessageProvider implements 
MessageProvider {
     private MessageExt viewMessageByOffsetId(DefaultMQAdminExt adminExt, 
String topic, String msgId) {
         try {
             MessageId messageId = MessageDecoder.decodeMessageId(msgId);
-            SocketAddress address = messageId.getAddress();
-            if (!(address instanceof InetSocketAddress)) {
+            String brokerAddr = validatedBrokerAddr(adminExt, msgId, 
messageId);
+            if (!StringUtils.hasText(brokerAddr)) {
                 return null;
             }
-            InetSocketAddress inet = (InetSocketAddress) address;
-            String brokerAddr = inet.getAddress().getHostAddress() + ":" + 
inet.getPort();
             return adminExt.getDefaultMQAdminExtImpl()
                     .getMqClientInstance()
                     .getMQClientAPIImpl()
@@ -171,6 +175,51 @@ public class RocketMQMessageProvider implements 
MessageProvider {
         }
     }
 
+    private String validatedBrokerAddr(DefaultMQAdminExt adminExt, String 
msgId, MessageId messageId) throws Exception {
+        String brokerAddr = decodedBrokerAddr(messageId);
+        if (!StringUtils.hasText(brokerAddr)) {
+            return null;
+        }
+        if (knownBrokerEndpoints(adminExt).contains(brokerAddr)) {
+            return brokerAddr;
+        }
+        log.warn("Rejecting decoded broker address {} for msgId={} because it 
is not a known broker endpoint"
+                + " for the selected instance", brokerAddr, msgId);
+        return null;
+    }
+
+    private String decodedBrokerAddr(MessageId messageId) {
+        SocketAddress address = messageId.getAddress();
+        if (!(address instanceof InetSocketAddress)) {
+            return null;
+        }
+        InetSocketAddress inet = (InetSocketAddress) address;
+        if (inet.getAddress() == null) {
+            return null;
+        }
+        return inet.getAddress().getHostAddress() + ":" + inet.getPort();
+    }
+
+    private Set<String> knownBrokerEndpoints(DefaultMQAdminExt adminExt) 
throws Exception {
+        ClusterInfo clusterInfo = adminExt.examineBrokerClusterInfo();
+        if (clusterInfo == null || clusterInfo.getBrokerAddrTable() == null
+                || clusterInfo.getBrokerAddrTable().isEmpty()) {
+            return Collections.emptySet();
+        }
+        Set<String> endpoints = new HashSet<>();
+        for (BrokerData brokerData : 
clusterInfo.getBrokerAddrTable().values()) {
+            if (brokerData == null || brokerData.getBrokerAddrs() == null || 
brokerData.getBrokerAddrs().isEmpty()) {
+                continue;
+            }
+            for (String brokerAddr : brokerData.getBrokerAddrs().values()) {
+                if (StringUtils.hasText(brokerAddr)) {
+                    endpoints.add(brokerAddr.trim());
+                }
+            }
+        }
+        return endpoints;
+    }
+
     private List<MessageRecordVO> queryByKey(DefaultMQAdminExt adminExt, 
String topic, String key,
                                              String tag, long begin, long end) 
{
         try {
@@ -207,16 +256,23 @@ public class RocketMQMessageProvider implements 
MessageProvider {
                 return Collections.emptyList();
             }
             for (MessageQueue queue : queues) {
-                long minOffset = consumer.searchOffset(queue, begin);
-                long maxOffset = consumer.searchOffset(queue, end);
+                TopicQueueScanPlan scanPlan = 
buildTopicQueueScanPlan(consumer, queue, begin, end);
+                if (scanPlan.isEmpty()) {
+                    continue;
+                }
+                if (scanPlan.truncated()) {
+                    log.info("Truncate topic query for {} queue {} to offsets 
[{}..{}) within the guarded tail budget",
+                            topic, queue, scanPlan.startOffset(), 
scanPlan.endOffsetExclusive());
+                }
                 int consecutiveIllegalOffsets = 0;
                 int pullAttempts = 0;
-                for (long offset = minOffset; offset <= maxOffset; ) {
-                    if (++pullAttempts > MAX_PULLS_PER_QUEUE) {
-                        throw new BusinessException(400,
-                                "Topic message query exceeded the per-queue 
pull budget; narrow the time range");
+                for (long offset = scanPlan.startOffset(); offset < 
scanPlan.endOffsetExclusive(); ) {
+                    if (++pullAttempts > MAX_PULL_ATTEMPTS_PER_QUEUE) {
+                        log.warn("Stop topic query for {} because queue {} 
exhausted the guarded pull budget at offset {}",
+                                topic, queue, offset);
+                        break;
                     }
-                    PullResult pullResult = consumer.pull(queue, "*", offset, 
32);
+                    PullResult pullResult = consumer.pull(queue, "*", offset, 
TOPIC_PULL_BATCH_SIZE);
                     if (pullResult == null) {
                         log.warn("Stop topic query for {} because queue {} 
returned no pull result", topic, queue);
                         break;
@@ -226,7 +282,7 @@ public class RocketMQMessageProvider implements 
MessageProvider {
                         log.warn("Stop topic query for {} because queue {} did 
not advance offset {}", topic, queue, offset);
                         break;
                     }
-                    offset = nextOffset;
+                    offset = Math.min(nextOffset, 
scanPlan.endOffsetExclusive());
                     if (pullResult.getPullStatus() == 
PullStatus.OFFSET_ILLEGAL) {
                         // The broker returned a corrected offset in 
nextBeginOffset because
                         // the requested offset is no longer valid (expired, 
compacted, or
@@ -260,8 +316,6 @@ public class RocketMQMessageProvider implements 
MessageProvider {
                     }
                 }
             }
-        } catch (BusinessException exception) {
-            throw exception;
         } catch (Exception e) {
             log.warn("queryByTopic(topic={}) failed: {}", topic, 
e.getMessage());
             throw new BusinessException(502, "Failed to query messages by 
topic: " + e.getMessage());
@@ -273,6 +327,33 @@ public class RocketMQMessageProvider implements 
MessageProvider {
                 .toList();
     }
 
+    private TopicQueueScanPlan buildTopicQueueScanPlan(DefaultMQPullConsumer 
consumer, MessageQueue queue,
+                                                       long begin, long end) 
throws Exception {
+        long minOffset = consumer.minOffset(queue);
+        long maxOffsetExclusive = consumer.maxOffset(queue);
+        if (maxOffsetExclusive <= minOffset) {
+            return TopicQueueScanPlan.empty();
+        }
+        long windowStartOffset = clampOffset(consumer.searchOffset(queue, 
begin), minOffset, maxOffsetExclusive);
+        long windowEndOffsetExclusive = 
clampOffset(consumer.searchOffset(queue, inclusiveUpperBound(end)),
+                windowStartOffset, maxOffsetExclusive);
+        if (windowEndOffsetExclusive <= windowStartOffset) {
+            return TopicQueueScanPlan.empty();
+        }
+        long budgetedStartOffset = Math.max(windowStartOffset,
+                windowEndOffsetExclusive - MAX_TOPIC_SCAN_MESSAGES_PER_QUEUE);
+        return new TopicQueueScanPlan(budgetedStartOffset, 
windowEndOffsetExclusive,
+                budgetedStartOffset > windowStartOffset);
+    }
+
+    private long clampOffset(long offset, long minOffset, long 
maxOffsetExclusive) {
+        return Math.max(minOffset, Math.min(offset, maxOffsetExclusive));
+    }
+
+    private long inclusiveUpperBound(long timestamp) {
+        return timestamp == Long.MAX_VALUE ? Long.MAX_VALUE : timestamp + 1;
+    }
+
     private void addTopicQueryCandidate(PriorityQueue<MessageRecordVO> 
newestMessages,
                                         MessageRecordVO candidate, int 
resultLimit) {
         if (resultLimit <= 0) {
@@ -633,4 +714,14 @@ public class RocketMQMessageProvider implements 
MessageProvider {
     private static boolean parseBoolean(String value) {
         return "true".equalsIgnoreCase(value == null ? "" : value.trim());
     }
+
+    private record TopicQueueScanPlan(long startOffset, long 
endOffsetExclusive, boolean truncated) {
+        private static TopicQueueScanPlan empty() {
+            return new TopicQueueScanPlan(0L, 0L, false);
+        }
+
+        private boolean isEmpty() {
+            return endOffsetExclusive <= startOffset;
+        }
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
index d62ea19c7..37ac02dcc 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
@@ -25,7 +25,7 @@ import lombok.ToString;
 import org.springframework.util.StringUtils;
 
 @Data
-@Builder
+@Builder(toBuilder = true)
 @NoArgsConstructor
 @AllArgsConstructor
 public class GeneralSettingsVO {
@@ -57,4 +57,14 @@ public class GeneralSettingsVO {
     public boolean isApiKeyConfigured() {
         return StringUtils.hasText(apiKey);
     }
+
+    @JsonProperty(value = "dingtalkWebhookConfigured", access = 
JsonProperty.Access.READ_ONLY)
+    public boolean isDingtalkWebhookConfigured() {
+        return StringUtils.hasText(dingtalkWebhook);
+    }
+
+    @JsonProperty(value = "smsWebhookConfigured", access = 
JsonProperty.Access.READ_ONLY)
+    public boolean isSmsWebhookConfigured() {
+        return StringUtils.hasText(smsWebhook);
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java 
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
index 03388394a..ecb125dcc 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.settings;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.apache.rocketmq.studio.cluster.metrics.MetricsBackendType;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
@@ -52,6 +53,7 @@ import java.util.Set;
 @Service
 public class SettingsService {
 
+    private static final String REDACTED_NOTIFICATION_WEBHOOK = "******";
     private static final List<byte[]> CLOUD_METADATA_ADDRESSES = List.of(
             new byte[] {
                 (byte) 0xfd, 0x00, 0x0e, (byte) 0xc2,
@@ -98,7 +100,25 @@ public class SettingsService {
 
     public GeneralSettingsVO getGeneralSettings() {
         log.debug("Loading general settings");
-        return settingsRepository.loadGeneralSettings();
+        GeneralSettingsVO settings = settingsRepository.loadGeneralSettings();
+        if (AuthenticatedUserContext.currentUserIsAdminOrSystem()) {
+            return settings;
+        }
+        return redactNotificationWebhooks(settings);
+    }
+
+    private GeneralSettingsVO redactNotificationWebhooks(GeneralSettingsVO 
settings) {
+        if (settings == null) {
+            return null;
+        }
+        return settings.toBuilder()
+                
.dingtalkWebhook(StringUtils.hasText(settings.getDingtalkWebhook())
+                        ? REDACTED_NOTIFICATION_WEBHOOK
+                        : settings.getDingtalkWebhook())
+                .smsWebhook(StringUtils.hasText(settings.getSmsWebhook())
+                        ? REDACTED_NOTIFICATION_WEBHOOK
+                        : settings.getSmsWebhook())
+                .build();
     }
 
 
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCredentialAuthorizationIntegrationTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCredentialAuthorizationIntegrationTest.java
index 4615078cd..0324ae0d8 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCredentialAuthorizationIntegrationTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCredentialAuthorizationIntegrationTest.java
@@ -31,6 +31,8 @@ import org.springframework.context.annotation.Import;
 import org.springframework.http.HttpHeaders;
 import org.springframework.test.web.servlet.MockMvc;
 
+import java.util.Optional;
+
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.verify;
@@ -72,6 +74,7 @@ class AuthCredentialAuthorizationIntegrationTest {
         when(authProperties.isLoginRequired()).thenReturn(true);
         when(authService.isAuthenticated(AUTHORIZATION)).thenReturn(true);
         when(authService.isAdmin(AUTHORIZATION)).thenReturn(false);
+        
when(authService.getAuthenticatedUser(AUTHORIZATION)).thenReturn(Optional.of(user(false)));
     }
 
     @Test
@@ -95,6 +98,7 @@ class AuthCredentialAuthorizationIntegrationTest {
     @Test
     void shouldAllowCredentialPathsWithMatrixParametersForAdministrator() 
throws Exception {
         when(authService.isAdmin(AUTHORIZATION)).thenReturn(true);
+        
when(authService.getAuthenticatedUser(AUTHORIZATION)).thenReturn(Optional.of(user(true)));
 
         mockMvc.perform(get("/api/acl/users/user-1/credentials;probe=1")
                         .header(HttpHeaders.AUTHORIZATION, AUTHORIZATION))
@@ -106,4 +110,12 @@ class AuthCredentialAuthorizationIntegrationTest {
         verify(aclService).getUserCredentials(eq("user-1"), isNull());
         verify(cloudCredentialService).reveal(12L);
     }
+
+    private LoginVO.UserInfo user(boolean admin) {
+        return LoginVO.UserInfo.builder()
+                .userId(1L)
+                .username(admin ? "admin" : "reader")
+                .admin(admin)
+                .build();
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
index a2acb6a15..5fc95a5fe 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
@@ -99,6 +99,26 @@ class AuthInterceptorTest {
                 .isEqualTo(AuthenticatedUserContext.SYSTEM_ACTOR);
     }
 
+    @Test
+    void shouldTrackAuthenticatedAdminStateInUserContext() throws Exception {
+        TestSession session = login(true);
+        MockHttpServletRequest request = authenticatedRequest("GET", 
"/api/clusters", session.token());
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        Object handler = new Object();
+
+        boolean allowed = session.interceptor().preHandle(request, response, 
handler);
+
+        assertThat(allowed).isTrue();
+        
assertThat(AuthenticatedUserContext.currentUsernameOrSystem()).isEqualTo("test-user");
+        
assertThat(AuthenticatedUserContext.currentUserIsAdminOrSystem()).isTrue();
+
+        session.interceptor().afterCompletion(request, response, handler, 
null);
+
+        assertThat(AuthenticatedUserContext.currentUsernameOrSystem())
+                .isEqualTo(AuthenticatedUserContext.SYSTEM_ACTOR);
+        
assertThat(AuthenticatedUserContext.currentUserIsAdminOrSystem()).isTrue();
+    }
+
     @Test
     void shouldEnforceLoginWhenDatabaseRequiresItEvenIfPropertyIsDisabled() 
throws Exception {
         AuthProperties properties = new AuthProperties();
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
index 5327cd3a1..9ef733e6a 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
@@ -159,6 +159,19 @@ class AuthServiceDatabaseTest {
         
assertThat(authService.login(request).getUser().getUsername()).isEqualTo("operator");
     }
 
+    @Test
+    void createUserShouldReturnConflictWhenConcurrentInsertWins() {
+        when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null);
+        when(userMapper.insert(any(RmqStudioUser.class)))
+                .thenThrow(new 
org.springframework.dao.DuplicateKeyException("duplicate username"));
+
+        assertThatThrownBy(() -> authService.createUser("operator", 
"password-1", false))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Username is already in use")
+                .satisfies(exception ->
+                        assertThat(((BusinessException) 
exception).getCode()).isEqualTo(409));
+    }
+
     @Test
     void repeatedFailedLoginsAreRejectedWithTooManyRequestsTest() {
         when(userMapper.selectCount(isNull())).thenReturn(1L);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterServiceTest.java
index 976d5e747..23d850555 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterServiceTest.java
@@ -202,6 +202,25 @@ class ClusterServiceTest {
                 .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(503));
     }
 
+    @Test
+    void listProxiesShouldUseResolvedCluster() {
+        
when(clusterProvider.refreshClusterDetail("cluster-1")).thenReturn(sampleCluster);
+
+        List<ProxyVO> proxies = clusterService.listProxies("cluster-1");
+
+        
assertThat(proxies).extracting(ProxyVO::getAddr).containsExactly("10.0.0.10:8081");
+    }
+
+    @Test
+    void requireProxyShouldRejectUnknownAddress() {
+        
when(clusterProvider.refreshClusterDetail("cluster-1")).thenReturn(sampleCluster);
+
+        assertThatThrownBy(() -> clusterService.requireProxy("cluster-1", 
"127.0.0.1:8081"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Proxy not found: 127.0.0.1:8081")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(404));
+    }
+
     @Test
     void updateConfigShouldUpdateFlushDiskType() {
         
when(clusterRepository.findById("cluster-1")).thenReturn(Optional.of(sampleCluster));
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MultiBackendMetricsSourceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MultiBackendMetricsSourceTest.java
index a6bdb5f7d..cd49a949c 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MultiBackendMetricsSourceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MultiBackendMetricsSourceTest.java
@@ -20,9 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.sun.net.httpserver.HttpExchange;
 import com.sun.net.httpserver.HttpServer;
 import org.apache.rocketmq.studio.model.MetricsDataSourceConfig;
-import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
@@ -30,17 +28,8 @@ import org.junit.jupiter.params.provider.EnumSource;
 import org.springframework.web.client.RestClient;
 
 import java.io.IOException;
-import java.net.InetAddress;
 import java.net.InetSocketAddress;
-import java.net.InterfaceAddress;
-import java.net.NetworkInterface;
-import java.net.Proxy;
-import java.net.ProxySelector;
-import java.net.SocketAddress;
-import java.net.URI;
 import java.nio.charset.StandardCharsets;
-import java.util.Enumeration;
-import java.util.List;
 import java.util.concurrent.atomic.AtomicReference;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -52,38 +41,13 @@ class MultiBackendMetricsSourceTest {
     private String baseUrl;
     private final MetricsSourceFactory factory =
             new MetricsSourceFactory(RestClient.builder(), new ObjectMapper());
-
-    private static ProxySelector originalProxySelector;
-
-    @BeforeAll
-    static void bypassJvmProxy() {
-        // The IDE (e.g. IDEA with a PAC proxy) may inject a proxy into the 
test JVM. The embedded
-        // server is bound to a site-local address that is not in 
http.nonProxyHosts, so the request
-        // would be routed through the proxy and time out. Force a direct 
connection for this test.
-        originalProxySelector = ProxySelector.getDefault();
-        ProxySelector.setDefault(new ProxySelector() {
-            @Override
-            public List<Proxy> select(URI uri) {
-                return List.of(Proxy.NO_PROXY);
-            }
-
-            @Override
-            public void connectFailed(URI uri, SocketAddress socketAddress, 
IOException exception) {
-                // Nothing to do; the test never relies on a proxy.
-            }
-        });
-    }
-
-    @AfterAll
-    static void restoreJvmProxy() {
-        ProxySelector.setDefault(originalProxySelector);
-    }
+    private final RestClient.Builder restClientBuilder = RestClient.builder();
+    private final ObjectMapper objectMapper = new ObjectMapper();
 
     @BeforeEach
     void setUp() throws IOException {
-        java.net.InetAddress bindAddress = findSiteLocalAddress();
-        server = HttpServer.create(new InetSocketAddress(bindAddress, 0), 0);
-        baseUrl = "http://"; + bindAddress.getHostAddress() + ":" + 
server.getAddress().getPort();
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        baseUrl = "http://127.0.0.1:"; + server.getAddress().getPort();
         server.start();
     }
 
@@ -105,8 +69,7 @@ class MultiBackendMetricsSourceTest {
                     """.formatted(backendType.name()));
         });
 
-        MetricsDataSourceConfig config = configFor(backendType);
-        MetricsSource source = factory.create(config);
+        MetricsSource source = testSourceFor(backendType);
         MetricDataVO result = source.query(query());
 
         assertThat(requestPath.get()).isEqualTo(backendType.getQueryPath());
@@ -179,7 +142,7 @@ class MultiBackendMetricsSourceTest {
                     """);
         });
 
-        factory.create(configWithAuth(authType, username, password, 
bearerToken)).query(query());
+        loopbackPrometheusSource(configWithAuth(authType, username, password, 
bearerToken)).query(query());
 
         assertThat(authorization.get()).isEqualTo(expectedAuthorization);
     }
@@ -187,7 +150,7 @@ class MultiBackendMetricsSourceTest {
     private void assertAuthenticationFailure(String authType, String username, 
String password,
                                              String bearerToken, String 
message) {
         assertThatExceptionOfType(PrometheusException.class)
-                .isThrownBy(() -> factory.create(configWithAuth(authType, 
username, password, bearerToken))
+                .isThrownBy(() -> 
loopbackPrometheusSource(configWithAuth(authType, username, password, 
bearerToken))
                         .query(query()))
                 .satisfies(exception -> {
                     assertThat(exception.getStatusCode()).isEqualTo(503);
@@ -195,6 +158,26 @@ class MultiBackendMetricsSourceTest {
                 });
     }
 
+    private MetricsSource loopbackPrometheusSource(MetricsDataSourceConfig 
config) {
+        MetricsSourceSettings settings = MetricsSourceSettings.builder()
+                .backendType(MetricsBackendType.PROMETHEUS)
+                .baseUrl(config.getUrl())
+                .authType(config.getAuthType())
+                .username(config.getUsername())
+                .password(config.getPassword())
+                .bearerToken(config.getBearerToken())
+                .build();
+        return new PrometheusMetricsSource(restClientBuilder, objectMapper, 
settings) {
+            @Override
+            protected void validateQueryHost(String url) {
+                if (url != null && url.startsWith(baseUrl)) {
+                    return;
+                }
+                super.validateQueryHost(url);
+            }
+        };
+    }
+
     private MetricsDataSourceConfig configWithAuth(String authType, String 
username,
                                                    String password, String 
bearerToken) {
         MetricsDataSourceConfig config = 
configFor(MetricsBackendType.PROMETHEUS);
@@ -213,6 +196,31 @@ class MultiBackendMetricsSourceTest {
         return config;
     }
 
+    private MetricsSource testSourceFor(MetricsBackendType backendType) {
+        return loopbackSource(backendType);
+    }
+
+    private AbstractPrometheusCompatibleMetricsSource 
loopbackSource(MetricsBackendType backendType) {
+        return new 
AbstractPrometheusCompatibleMetricsSource(restClientBuilder, objectMapper,
+                MetricsSourceSettings.builder()
+                        .backendType(backendType)
+                        .baseUrl(baseUrl)
+                        .build()) {
+            @Override
+            protected MetricsBackendType backendType() {
+                return backendType;
+            }
+
+            @Override
+            protected void validateQueryHost(String url) {
+                if (url != null && url.startsWith(baseUrl)) {
+                    return;
+                }
+                super.validateQueryHost(url);
+            }
+        };
+    }
+
     private MetricQueryDTO query() {
         return MetricQueryDTO.builder()
                 .metric("up")
@@ -230,34 +238,4 @@ class MultiBackendMetricsSourceTest {
         exchange.close();
     }
 
-    private static java.net.InetAddress findSiteLocalAddress() throws 
java.net.SocketException {
-        InetAddress fallback = null;
-        Enumeration<NetworkInterface> interfaces = 
NetworkInterface.getNetworkInterfaces();
-        while (interfaces.hasMoreElements()) {
-            NetworkInterface iface = interfaces.nextElement();
-            if (!iface.isUp() || iface.isLoopback()) {
-                continue;
-            }
-            for (InterfaceAddress address : iface.getInterfaceAddresses()) {
-                InetAddress inet = address.getAddress();
-                if (inet instanceof java.net.Inet4Address
-                        && !inet.isLoopbackAddress()
-                        && !inet.isLinkLocalAddress()) {
-                    if (inet.isSiteLocalAddress()) {
-                        return inet;
-                    }
-                    if (fallback == null) {
-                        fallback = inet;
-                    }
-                }
-            }
-        }
-        // No site-local interface (e.g. hosts that only expose public 
ranges): fall back to any
-        // non-loopback IPv4 so the embedded server stays reachable under the 
SSRF guard, which
-        // rejects loopback/link-local addresses (see UrlHostGuard).
-        if (fallback != null) {
-            return fallback;
-        }
-        return java.net.InetAddress.getLoopbackAddress();
-    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
index 90776ba06..21eacfc3d 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
@@ -17,9 +17,15 @@
 
 package org.apache.rocketmq.studio.cluster.proxy;
 
+import org.apache.rocketmq.studio.cluster.broker.ClusterService;
 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.junit.jupiter.MockitoExtension;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.client.RestTemplate;
 
 import java.util.List;
 
@@ -28,16 +34,29 @@ import static 
org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
+@ExtendWith(MockitoExtension.class)
 class ProxyAddressServiceTest {
 
+    @Mock
+    private ClusterService clusterService;
+
+    @Mock
+    private RestTemplate restTemplate;
+
     private final ProxyHealthProbe healthProbe = mock(ProxyHealthProbe.class);
-    private final ProxyAddressService proxyAddressService = new 
ProxyAddressService(healthProbe);
+    private ProxyAddressService proxyAddressService;
 
     @BeforeEach
     void setUp() {
+        proxyAddressService = new ProxyAddressService(clusterService, 
healthProbe, restTemplate);
         // Default probe outcome: everything reachable with 1 ms latency.
         when(healthProbe.probe(anyString(), anyInt(), anyInt()))
                 .thenReturn(ProxyHealthProbe.ProbeResult.reachable(1L));
@@ -141,11 +160,41 @@ class ProxyAddressServiceTest {
     }
 
     @Test
-    void reloadConfigShouldRejectUnregisteredAddressTest() {
-        assertThatThrownBy(() -> 
proxyAddressService.reloadConfig("10.0.0.1:8081"))
+    void reloadConfigShouldRejectLoopbackOutsideTrustedCluster() {
+        doThrow(new BusinessException(404, "Proxy not found: 127.0.0.2:8081"))
+                .when(clusterService).requireProxy("cluster-1", 
"127.0.0.2:8081");
+
+        assertThatThrownBy(() -> proxyAddressService.reloadConfig("cluster-1", 
"127.0.0.2:8081"))
                 .isInstanceOf(BusinessException.class)
-                .hasMessage("addr is not a registered proxy address")
-                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
+                .hasMessage("Proxy not found: 127.0.0.2:8081")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(404));
+
+        verifyNoInteractions(restTemplate);
+    }
+
+    @Test
+    void reloadConfigShouldRejectNonTrustedEndpoint() {
+        doThrow(new BusinessException(404, "Proxy not found: 
198.51.100.10:8081"))
+                .when(clusterService).requireProxy("cluster-1", 
"198.51.100.10:8081");
+
+        assertThatThrownBy(() -> proxyAddressService.reloadConfig("cluster-1", 
"198.51.100.10:8081"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Proxy not found: 198.51.100.10:8081")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(404));
+
+        verifyNoInteractions(restTemplate);
+    }
+
+    @Test
+    void reloadConfigShouldPostToTrustedDiscoveredProxy() {
+        doNothing().when(clusterService).requireProxy("cluster-1", 
"10.0.0.10:8081");
+        
when(restTemplate.postForEntity(eq("http://10.0.0.10:8081/admin/reloadConfig";), 
isNull(), eq(String.class)))
+                .thenReturn(ResponseEntity.ok("ok"));
+
+        proxyAddressService.reloadConfig("cluster-1", "10.0.0.10:8081");
+
+        verify(clusterService).requireProxy("cluster-1", "10.0.0.10:8081");
+        
verify(restTemplate).postForEntity(eq("http://10.0.0.10:8081/admin/reloadConfig";),
 isNull(), eq(String.class));
     }
 
     @Test
@@ -216,7 +265,7 @@ class ProxyAddressServiceTest {
             }
             return ProxyHealthProbe.ProbeResult.reachable(300L);
         };
-        ProxyAddressService service = new ProxyAddressService(slowProbe);
+        ProxyAddressService service = new ProxyAddressService(clusterService, 
slowProbe);
         service.addProxyAddr("10.0.0.11:8081");
         service.addProxyAddr("10.0.0.12:8081");
         service.addProxyAddr("10.0.0.13:8081");
@@ -246,7 +295,7 @@ class ProxyAddressServiceTest {
         };
         java.util.concurrent.ExecutorService executor =
                 java.util.concurrent.Executors.newFixedThreadPool(4);
-        ProxyAddressService service = new ProxyAddressService(selectiveProbe, 
executor, 500L);
+        ProxyAddressService service = new ProxyAddressService(clusterService, 
selectiveProbe, executor, 500L);
         service.addProxyAddr("10.0.0.21:8081");
         service.addProxyAddr("10.0.0.22:8081");
 
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
index 01c2c3ee0..7133a8d8b 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
@@ -29,6 +29,7 @@ import org.springframework.test.web.servlet.MockMvc;
 import java.util.List;
 
 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;
@@ -105,18 +106,15 @@ class ProxyControllerTest {
 
     @Test
     void listProxiesShouldReturnProxiesForCluster() throws Exception {
-        when(proxyAddressService.getHomePage())
-                .thenReturn(ProxyHomeVO.builder()
-                        .proxyAddrList(List.of("127.0.0.1:8081"))
-                        .currentProxyAddr("127.0.0.1:8081")
-                        .build());
+        when(clusterService.listProxies("cluster-1"))
+                
.thenReturn(List.of(ProxyVO.builder().addr("10.0.0.10:8081").build()));
 
         mockMvc.perform(get("/api/proxies").param("clusterId", "cluster-1"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data[0].addr").value("127.0.0.1:8081"));
+                .andExpect(jsonPath("$.data[0].addr").value("10.0.0.10:8081"));
 
-        verify(proxyAddressService).getHomePage();
+        verify(clusterService).listProxies("cluster-1");
     }
 
     @Test
@@ -176,7 +174,7 @@ class ProxyControllerTest {
                 .andExpect(jsonPath("$.code").value(200))
                 .andExpect(jsonPath("$.data.success").value(true));
 
-        verify(proxyAddressService).reloadConfig("127.0.0.1:8081");
+        verify(proxyAddressService).reloadConfig(eq("cluster-1"), 
eq("127.0.0.1:8081"));
     }
 
     @Test
@@ -194,4 +192,4 @@ class ProxyControllerTest {
 
         verifyNoInteractions(proxyAddressService);
     }
-}
\ No newline at end of file
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
index c4206909d..383b6830f 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
@@ -578,7 +578,9 @@ class RocketMQDLQProviderTest {
         }
         verify(auditService).record(
                 eq("RESEND_DLQ"),
+                eq("DLQ"),
                 eq("group-a"),
+                eq(null),
                 contains("scanTruncated=true"),
                 eq("PARTIAL"));
     }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
index 126d72d25..2eb00365b 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
@@ -28,6 +28,8 @@ import org.apache.rocketmq.common.message.MessageExt;
 import org.apache.rocketmq.common.message.MessageId;
 import org.apache.rocketmq.common.message.MessageQueue;
 import org.apache.rocketmq.remoting.RPCHook;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
 import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
 import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
@@ -49,11 +51,14 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import java.net.InetSocketAddress;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.IntStream;
+import java.util.stream.LongStream;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -84,12 +89,22 @@ class RocketMQMessageProviderTest {
     private RocketMQMessageProvider provider;
 
     @BeforeEach
-    void setUp() {
+    void setUp() throws Exception {
         
lenient().when(runtimeAdminClientResolver.resolveEndpoint("instance-a")).thenReturn("namesrv-a:9876");
         lenient().when(runtimeAdminClientResolver.execute(anyString(), 
any())).thenAnswer(invocation -> {
             MqAdminExtFactory.AdminAction<Object> action = 
invocation.getArgument(1);
-            return action == null ? null : action.apply(adminExt);
+            if (action == null) {
+                return null;
+            }
+            try {
+                return action.apply(adminExt);
+            } catch (InterruptedException exception) {
+                Thread.currentThread().interrupt();
+                throw exception;
+            }
         });
+        lenient().when(adminExt.examineBrokerClusterInfo())
+                
.thenReturn(clusterInfoWithBrokerAddresses("172.30.10.100:10911"));
         provider = new RocketMQMessageProvider(runtimeAdminClientResolver);
     }
 
@@ -213,6 +228,20 @@ class RocketMQMessageProviderTest {
         verify(clientApi).viewMessage("172.30.10.100:10911", "TopicA", 
27521713L, 3000L);
     }
 
+    @Test
+    void queryByMsgIdRejectsDecodedBrokerOutsideKnownTopology() throws 
Exception {
+        String msgId = MessageDecoder.createMessageId(new 
InetSocketAddress("10.2.3.4", 10911), 12345L);
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfoWithBrokerAddresses("172.30.10.100:10911"));
+        when(adminExt.viewMessage("TopicA", msgId))
+                .thenThrow(new IllegalStateException("primary lookup failed"));
+
+        List<MessageRecordVO> result = provider.queryMessages(
+                "instance-a", "TopicA", msgId, null, null, 100L, 200L);
+
+        assertThat(result).isEmpty();
+        verify(adminExt, never()).getDefaultMQAdminExtImpl();
+    }
+
     @Test
     void queryByTopicSurfacesPullConsumerFailure() throws Exception {
         try (MockedConstruction<DefaultMQPullConsumer> ignored =
@@ -237,7 +266,7 @@ class RocketMQMessageProviderTest {
                      mockConstruction(DefaultMQPullConsumer.class, (consumer, 
context) -> {
                          doNothing().when(consumer).start();
                          
when(consumer.fetchSubscribeMessageQueues("TopicA")).thenReturn(Set.of(queue));
-                         when(consumer.searchOffset(eq(queue), 
anyLong())).thenReturn(10L);
+                         mockQueueWindow(consumer, queue, 100L, 200L, 10L, 
10L, 11L, 11L);
                          when(consumer.pull(eq(queue), eq("*"), eq(10L), 
eq(32))).thenReturn(stalledResult);
                          doNothing().when(consumer).shutdown();
                      })) {
@@ -262,7 +291,8 @@ class RocketMQMessageProviderTest {
                          doNothing().when(consumer).start();
                          when(consumer.fetchSubscribeMessageQueues("TopicA"))
                                  .thenReturn(new 
LinkedHashSet<>(List.of(olderQueue, newerQueue)));
-                         when(consumer.searchOffset(any(MessageQueue.class), 
anyLong())).thenReturn(10L);
+                         mockQueueWindow(consumer, olderQueue, 100L, 300L, 
10L, 10L, 11L, 11L);
+                         mockQueueWindow(consumer, newerQueue, 100L, 300L, 
10L, 10L, 11L, 11L);
                          when(consumer.pull(olderQueue, "*", 10L, 
32)).thenReturn(olderPullResult);
                          when(consumer.pull(newerQueue, "*", 10L, 
32)).thenReturn(newerPullResult);
                          doNothing().when(consumer).shutdown();
@@ -290,8 +320,7 @@ class RocketMQMessageProviderTest {
                      mockConstruction(DefaultMQPullConsumer.class, (consumer, 
context) -> {
                          doNothing().when(consumer).start();
                          
when(consumer.fetchSubscribeMessageQueues("TopicA")).thenReturn(Set.of(queue));
-                         when(consumer.searchOffset(queue, 
100L)).thenReturn(10L);
-                         when(consumer.searchOffset(queue, 
200L)).thenReturn(50L);
+                         mockQueueWindow(consumer, queue, 100L, 200L, 10L, 
10L, 50L, 50L);
                          when(consumer.pull(eq(queue), eq("*"), eq(10L), 
eq(32))).thenReturn(illegalOffset);
                          when(consumer.pull(eq(queue), eq("*"), eq(20L), 
eq(32))).thenReturn(foundAfterCorrection);
                          when(consumer.pull(eq(queue), eq("*"), eq(40L), 
eq(32))).thenReturn(endOfQueue);
@@ -323,7 +352,8 @@ class RocketMQMessageProviderTest {
                          doNothing().when(consumer).start();
                          when(consumer.fetchSubscribeMessageQueues("TopicA"))
                                  .thenReturn(new 
LinkedHashSet<>(List.of(olderQueue, newerQueue)));
-                         when(consumer.searchOffset(any(MessageQueue.class), 
anyLong())).thenReturn(10L);
+                         mockQueueWindow(consumer, olderQueue, 100L, 300L, 
10L, 10L, 11L, 11L);
+                         mockQueueWindow(consumer, newerQueue, 100L, 300L, 
10L, 10L, 11L, 11L);
                          when(consumer.pull(olderQueue, "*", 10L, 
32)).thenReturn(olderPullResult);
                          when(consumer.pull(newerQueue, "*", 10L, 
32)).thenReturn(newerPullResult);
                          doNothing().when(consumer).shutdown();
@@ -351,7 +381,8 @@ class RocketMQMessageProviderTest {
                          doNothing().when(consumer).start();
                          when(consumer.fetchSubscribeMessageQueues("TopicA"))
                                  .thenReturn(new 
LinkedHashSet<>(List.of(firstQueue, secondQueue)));
-                         when(consumer.searchOffset(any(MessageQueue.class), 
anyLong())).thenReturn(10L);
+                         mockQueueWindow(consumer, firstQueue, 100L, 300L, 
10L, 10L, 11L, 11L);
+                         mockQueueWindow(consumer, secondQueue, 100L, 300L, 
10L, 10L, 11L, 11L);
                          when(consumer.pull(firstQueue, "*", 10L, 
32)).thenReturn(firstPullResult);
                          when(consumer.pull(secondQueue, "*", 10L, 
32)).thenReturn(secondPullResult);
                          doNothing().when(consumer).shutdown();
@@ -580,6 +611,83 @@ class RocketMQMessageProviderTest {
                 storeTimestamp + 24 * 60 * 60 * 1000L);
     }
 
+    @Test
+
+    void getMessageTraceDoesNotUseDecodedBrokerOutsideKnownTopology() throws 
Exception {
+        String msgId = MessageDecoder.createMessageId(new 
InetSocketAddress("10.2.3.4", 10911), 12345L);
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfoWithBrokerAddresses("172.30.10.100:10911"));
+        when(adminExt.viewMessage("TopicA", msgId))
+                .thenThrow(new IllegalStateException("topic lookup failed"));
+        when(adminExt.queryMessage(anyString(), anyString(), anyInt(), 
anyLong(), anyLong()))
+                .thenReturn(new QueryResult(0L, List.of()));
+
+        provider.getMessageTrace("instance-a", msgId, "TopicA");
+
+        verify(adminExt, never()).getDefaultMQAdminExtImpl();
+        ArgumentCaptor<Long> beginCaptor = ArgumentCaptor.forClass(Long.class);
+        ArgumentCaptor<Long> endCaptor = ArgumentCaptor.forClass(Long.class);
+        verify(adminExt).queryMessage(eq("RMQ_SYS_TRACE_TOPIC"), eq(msgId), 
eq(64),
+                beginCaptor.capture(), endCaptor.capture());
+        assertThat(endCaptor.getValue() - 
beginCaptor.getValue()).isBetween(3_660_000L, 3_670_000L);
+    }
+
+    @Test
+    void queryByTopicReturnsLatestMessagesWhenWindowExceedsLegacyPullLimit() 
throws Exception {
+        MessageQueue queue = new MessageQueue("TopicA", "broker-a", 0);
+        long begin = 10_000L;
+        long end = 49_999L;
+        long maxOffsetExclusive = 40_000L;
+        try (MockedConstruction<DefaultMQPullConsumer> mockedConsumers =
+                     mockConstruction(DefaultMQPullConsumer.class, (consumer, 
context) -> {
+                         doNothing().when(consumer).start();
+                         
when(consumer.fetchSubscribeMessageQueues("TopicA")).thenReturn(Set.of(queue));
+                         mockQueueWindow(consumer, queue, begin, end, 0L, 0L, 
maxOffsetExclusive, maxOffsetExclusive);
+                         when(consumer.pull(eq(queue), eq("*"), anyLong(), 
eq(32)))
+                                 .thenAnswer(invocation -> 
topicPullBatch(invocation.getArgument(2), maxOffsetExclusive, begin));
+                         doNothing().when(consumer).shutdown();
+                     })) {
+            List<MessageRecordVO> messages = provider.queryMessages(
+                    "instance-a", "TopicA", null, null, null, begin, end);
+
+            assertThat(messages).hasSize(200);
+            assertThat(messages.get(0).getMsgId()).isEqualTo("msg-39999");
+            assertThat(messages.get(199).getMsgId()).isEqualTo("msg-39800");
+            DefaultMQPullConsumer consumer = 
mockedConsumers.constructed().get(0);
+            verify(consumer).pull(queue, "*", 8_000L, 32);
+            verify(consumer, never()).pull(queue, "*", 0L, 32);
+        }
+    }
+
+    @Test
+    void 
queryByTopicKeepsPullOffsetsInsideTailBudgetWhenWindowIsLargerThanCap() throws 
Exception {
+        MessageQueue queue = new MessageQueue("TopicA", "broker-a", 0);
+        long begin = 20_000L;
+        long end = 69_999L;
+        long maxOffsetExclusive = 50_000L;
+        List<Long> pulledOffsets = new ArrayList<>();
+        try (MockedConstruction<DefaultMQPullConsumer> ignored =
+                     mockConstruction(DefaultMQPullConsumer.class, (consumer, 
context) -> {
+                         doNothing().when(consumer).start();
+                         
when(consumer.fetchSubscribeMessageQueues("TopicA")).thenReturn(Set.of(queue));
+                         mockQueueWindow(consumer, queue, begin, end, 0L, 0L, 
maxOffsetExclusive, maxOffsetExclusive);
+                         when(consumer.pull(eq(queue), eq("*"), anyLong(), 
eq(32)))
+                                 .thenAnswer(invocation -> {
+                                     long offset = invocation.getArgument(2);
+                                     pulledOffsets.add(offset);
+                                     return topicPullBatch(offset, 
maxOffsetExclusive, begin);
+                                 });
+                         doNothing().when(consumer).shutdown();
+                     })) {
+            List<MessageRecordVO> messages = provider.queryMessages(
+                    "instance-a", "TopicA", null, null, null, begin, end);
+
+            assertThat(messages).hasSize(200);
+            assertThat(pulledOffsets).isNotEmpty();
+            assertThat(pulledOffsets.get(0)).isEqualTo(18_000L);
+            assertThat(pulledOffsets).allMatch(offset -> offset >= 18_000L);
+        }
+    }
+
     private MQClientAPIImpl mockOffsetLookupClient() {
         DefaultMQAdminExtImpl adminExtImpl = mock(DefaultMQAdminExtImpl.class);
         MQClientInstance clientInstance = mock(MQClientInstance.class);
@@ -590,6 +698,32 @@ class RocketMQMessageProviderTest {
         return clientApi;
     }
 
+
+    private static ClusterInfo clusterInfoWithBrokerAddresses(String... 
brokerAddresses) {
+        ClusterInfo clusterInfo = new ClusterInfo();
+        Map<String, BrokerData> brokerAddrTable = new HashMap<>();
+        for (int index = 0; index < brokerAddresses.length; index++) {
+            BrokerData brokerData = new BrokerData();
+            brokerData.setBrokerName("broker-" + index);
+            brokerData.setCluster("cluster-a");
+            HashMap<Long, String> brokerAddrs = new HashMap<>();
+            brokerAddrs.put(0L, brokerAddresses[index]);
+            brokerData.setBrokerAddrs(brokerAddrs);
+            brokerAddrTable.put(brokerData.getBrokerName(), brokerData);
+        }
+        clusterInfo.setBrokerAddrTable(brokerAddrTable);
+        return clusterInfo;
+    }
+
+    private static void mockQueueWindow(DefaultMQPullConsumer consumer, 
MessageQueue queue, long begin, long end,
+                                        long minOffset, long startOffset, long 
endOffsetExclusive,
+                                        long maxOffsetExclusive) throws 
Exception {
+        when(consumer.minOffset(queue)).thenReturn(minOffset);
+        when(consumer.maxOffset(queue)).thenReturn(maxOffsetExclusive);
+        when(consumer.searchOffset(queue, begin)).thenReturn(startOffset);
+        when(consumer.searchOffset(queue, end + 
1)).thenReturn(endOffsetExclusive);
+    }
+
     private static String traceContext(String... fields) {
         return String.join(String.valueOf(TraceConstants.CONTENT_SPLITOR), 
fields);
     }
@@ -607,4 +741,12 @@ class RocketMQMessageProviderTest {
         message.setBody(("body-" + msgId).getBytes(StandardCharsets.UTF_8));
         return message;
     }
+
+    private static PullResult topicPullBatch(long offset, long 
endOffsetExclusive, long storeTimeBase) {
+        long batchEnd = Math.min(offset + 32, endOffsetExclusive);
+        List<MessageExt> messages = LongStream.range(offset, batchEnd)
+                .mapToObj(index -> topicMessage("msg-" + index, storeTimeBase 
+ index))
+                .toList();
+        return new PullResult(PullStatus.FOUND, batchEnd, 0L, 
endOffsetExclusive, messages);
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
index c27ab7021..85630ecd0 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.studio.settings;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -88,6 +89,41 @@ class SettingsControllerTest {
                 .andExpect(jsonPath("$.data.model", is("gpt-4")));
     }
 
+    @Test
+    void getGeneralSettingsShouldRedactNotificationWebhooksForReaders() throws 
Exception {
+        AuthenticatedUserContext.setUser("reader", false);
+        try {
+            GeneralSettingsVO settings = GeneralSettingsVO.builder()
+                    .theme("dark")
+                    .compact(true)
+                    .desktopNotify(true)
+                    .notifySound(false)
+                    .sessionTimeout(30)
+                    .requireLogin(true)
+                    .llmProvider("openai")
+                    
.dingtalkWebhook("https://oapi.dingtalk.com/robot/send?access_token=secret";)
+                    .emailRecipients("[email protected]")
+                    .smsWebhook("https://sms.example.test/notify";)
+                    .model("gpt-4")
+                    .baseUrl("https://api.openai.com";)
+                    .build();
+            
when(settingsService.getGeneralSettings()).thenReturn(settings.toBuilder()
+                    .dingtalkWebhook("******")
+                    .smsWebhook("******")
+                    .build());
+
+            mockMvc.perform(get("/api/settings/general"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.dingtalkWebhook", 
is("******")))
+                    .andExpect(jsonPath("$.data.dingtalkWebhookConfigured", 
is(true)))
+                    .andExpect(jsonPath("$.data.emailRecipients", 
is("[email protected]")))
+                    .andExpect(jsonPath("$.data.smsWebhook", is("******")))
+                    .andExpect(jsonPath("$.data.smsWebhookConfigured", 
is(true)));
+        } finally {
+            AuthenticatedUserContext.clear();
+        }
+    }
+
     @Test
     void saveGeneralSettingsShouldReturnSuccess() throws Exception {
         
doNothing().when(settingsService).saveGeneralSettings(any(GeneralSettingsVO.class));
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
index fa01e6b3d..2ff5fdc39 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.studio.settings;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.junit.jupiter.api.AfterEach;
@@ -117,6 +118,28 @@ class SettingsServiceTest {
         assertThat(result.getModel()).isEqualTo("gpt-4");
     }
 
+    @Test
+    void getGeneralSettingsShouldRedactNotificationWebhooksForReaderSessions() 
{
+        GeneralSettingsVO settings = GeneralSettingsVO.builder()
+                .theme("dark")
+                
.dingtalkWebhook("https://oapi.dingtalk.com/robot/send?access_token=secret";)
+                .smsWebhook("https://sms.example.test/notify";)
+                .build();
+        when(settingsRepository.loadGeneralSettings()).thenReturn(settings);
+        AuthenticatedUserContext.setUser("reader", false);
+
+        try {
+            GeneralSettingsVO result = settingsService.getGeneralSettings();
+
+            assertThat(result.getDingtalkWebhook()).isEqualTo("******");
+            assertThat(result.isDingtalkWebhookConfigured()).isTrue();
+            assertThat(result.getSmsWebhook()).isEqualTo("******");
+            assertThat(result.isSmsWebhookConfigured()).isTrue();
+        } finally {
+            AuthenticatedUserContext.clear();
+        }
+    }
+
     @Test
     void saveGeneralSettingsShouldPreserveExistingApiKeyWhenOmitted() {
         GeneralSettingsVO existing = GeneralSettingsVO.builder()
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index aef65392a..506d43fdc 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -114,6 +114,7 @@ describe('AuthGate', () => {
     renderGate();
 
     expect(await screen.findByText('protected content')).toBeInTheDocument();
+    expect(useAuthStore.getState()).toMatchObject({ user: null, userId: null, 
admin: null });
   });
 
   it('allows protected routes for an authenticated session', async () => {
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 2fa92870f..c74e21692 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -71,7 +71,12 @@ export function AuthGate() {
         if (status.authenticated && status.user) {
           syncAuth(status.user.username, status.user.userId, 
status.user.admin);
         }
-        if (!status.loginRequired || status.authenticated) {
+        if (!status.loginRequired) {
+          clearAuth();
+          setGateState('allowed');
+          return;
+        }
+        if (status.authenticated) {
           setGateState('allowed');
           return;
         }
diff --git a/web/src/api/generalSettings.test.ts 
b/web/src/api/generalSettings.test.ts
index b8928fb09..e59711e2a 100644
--- a/web/src/api/generalSettings.test.ts
+++ b/web/src/api/generalSettings.test.ts
@@ -33,6 +33,11 @@ const settings: GeneralSettings = {
   apiKeyConfigured: true,
   model: 'gpt-5',
   baseUrl: 'https://api.example.com/v1',
+  dingtalkWebhook: '******',
+  dingtalkWebhookConfigured: true,
+  emailRecipients: '[email protected]',
+  smsWebhook: '******',
+  smsWebhookConfigured: true,
 };
 const editableSettings: GeneralSettingsUpdate = {
   theme: settings.theme,
@@ -82,6 +87,8 @@ describe('general settings API', () => {
       const body = JSON.parse(config.data);
       expect(body).not.toHaveProperty('apiKey');
       expect(body).not.toHaveProperty('apiKeyConfigured');
+      expect(body).not.toHaveProperty('dingtalkWebhookConfigured');
+      expect(body).not.toHaveProperty('smsWebhookConfigured');
       return [200, { code: 200, data: null }];
     });
 
diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts
index 246a0a5e0..b221cf937 100644
--- a/web/src/api/settings.ts
+++ b/web/src/api/settings.ts
@@ -30,11 +30,16 @@ export interface GeneralSettings {
   model: string;
   baseUrl: string;
   dingtalkWebhook?: string;
+  dingtalkWebhookConfigured?: boolean;
   emailRecipients?: string;
   smsWebhook?: string;
+  smsWebhookConfigured?: boolean;
 }
 
-export type GeneralSettingsUpdate = Omit<GeneralSettings, 'apiKeyConfigured'> 
& {
+export type GeneralSettingsUpdate = Omit<
+  GeneralSettings,
+  'apiKeyConfigured' | 'dingtalkWebhookConfigured' | 'smsWebhookConfigured'
+> & {
   apiKey?: string;
   clearApiKey?: boolean;
 };
@@ -59,8 +64,16 @@ export async function getGeneralSettings() {
 }
 
 export async function saveGeneralSettings(data: GeneralSettingsUpdate) {
-  const payload = { ...data } as GeneralSettingsUpdate & { apiKeyConfigured?: 
boolean };
+  const payload = {
+    ...data,
+  } as GeneralSettingsUpdate & {
+    apiKeyConfigured?: boolean;
+    dingtalkWebhookConfigured?: boolean;
+    smsWebhookConfigured?: boolean;
+  };
   delete payload.apiKeyConfigured;
+  delete payload.dingtalkWebhookConfigured;
+  delete payload.smsWebhookConfigured;
   if (!payload.apiKey?.trim()) delete payload.apiKey;
   await client.post('/settings/general/save', payload);
 }
diff --git a/web/src/pages/studio/UserManagement.tsx 
b/web/src/pages/studio/UserManagement.tsx
index d1bffe348..98d3933d8 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -18,6 +18,7 @@ import { useCallback, useEffect, useState } from 'react';
 import { Button, Card, Form, Input, Modal, Space, Switch, Table, Tag, message 
} from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import { Key, Plus } from '@phosphor-icons/react';
+import { useNavigate } from 'react-router-dom';
 import PageHeader from '../../components/PageHeader';
 import InfoBanner from '../../components/InfoBanner';
 import { changePassword } from '../../api/auth';
@@ -44,8 +45,10 @@ interface PasswordFormValues {
 const dateTime = (value?: string) => (value ? new Date(value).toLocaleString() 
: '-');
 
 const UserManagementPage = () => {
+  const navigate = useNavigate();
   const admin = useAuthStore((state) => state.admin);
   const userId = useAuthStore((state) => state.userId);
+  const clearAuth = useAuthStore((state) => state.logout);
   const [users, setUsers] = useState<StudioUser[]>([]);
   const [loading, setLoading] = useState(false);
   const [createOpen, setCreateOpen] = useState(false);
@@ -101,6 +104,8 @@ const UserManagementPage = () => {
     try {
       if (passwordTarget.id === userId) {
         await changePassword(values.currentPassword ?? '', values.newPassword);
+        clearAuth();
+        navigate('/login', { replace: true });
         message.success('密码已修改,请使用新密码重新登录');
       } else {
         await resetStudioUserPassword(passwordTarget.id, values.newPassword);
@@ -124,7 +129,8 @@ const UserManagementPage = () => {
     {
       title: '状态',
       dataIndex: 'enabled',
-      render: (value: boolean) => (value ? <Tag color="green">已启用</Tag> : <Tag 
color="default">已禁用</Tag>),
+      render: (value: boolean) =>
+        value ? <Tag color="green">已启用</Tag> : <Tag color="default">已禁用</Tag>,
     },
     { title: '创建时间', dataIndex: 'gmtCreate', render: dateTime },
     {
@@ -186,12 +192,21 @@ const UserManagementPage = () => {
       </Card>
       {admin && <Table rowKey="id" loading={loading} columns={columns} 
dataSource={users} />}
 
-      <Modal title="新建 Studio 用户" open={createOpen} onOk={() => void 
createUser()} onCancel={() => setCreateOpen(false)}>
+      <Modal
+        title="新建 Studio 用户"
+        open={createOpen}
+        onOk={() => void createUser()}
+        onCancel={() => setCreateOpen(false)}
+      >
         <Form form={createForm} layout="vertical" initialValues={{ admin: 
false }}>
           <Form.Item name="username" label="用户名" rules={[{ required: true }, { 
max: 128 }]}>
             <Input autoComplete="username" />
           </Form.Item>
-          <Form.Item name="password" label="初始密码" rules={[{ required: true }, 
{ min: 8, message: '密码至少 8 位' }]}>
+          <Form.Item
+            name="password"
+            label="初始密码"
+            rules={[{ required: true }, { min: 8, message: '密码至少 8 位' }]}
+          >
             <Input.Password autoComplete="new-password" />
           </Form.Item>
           <Form.Item name="admin" label="管理员权限" valuePropName="checked">
@@ -201,7 +216,11 @@ const UserManagementPage = () => {
       </Modal>
 
       <Modal
-        title={passwordTarget?.id === userId ? '修改我的密码' : `重置 
${passwordTarget?.username ?? ''} 的密码`}
+        title={
+          passwordTarget?.id === userId
+            ? '修改我的密码'
+            : `重置 ${passwordTarget?.username ?? ''} 的密码`
+        }
         open={passwordTarget !== null}
         onOk={() => void updatePassword()}
         onCancel={() => {
@@ -215,7 +234,11 @@ const UserManagementPage = () => {
               <Input.Password autoComplete="current-password" />
             </Form.Item>
           )}
-          <Form.Item name="newPassword" label="新密码" rules={[{ required: true 
}, { min: 8, message: '密码至少 8 位' }]}>
+          <Form.Item
+            name="newPassword"
+            label="新密码"
+            rules={[{ required: true }, { min: 8, message: '密码至少 8 位' }]}
+          >
             <Input.Password autoComplete="new-password" />
           </Form.Item>
         </Form>

Reply via email to