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 ad16aebba fix(dlq): encode export filenames safely and expose scan 
completeness (#2506)
ad16aebba is described below

commit ad16aebba7752c7d00751dc095ef6426e94960d2
Author: btlqql <[email protected]>
AuthorDate: Tue Aug 25 16:29:59 2026 +0800

    fix(dlq): encode export filenames safely and expose scan completeness 
(#2506)
    
    * fix(dlq): encode export filenames safely
    
    The export endpoint concatenated the group name directly into a quoted
    Content-Disposition header, so names containing quotes, backslashes or
    control characters produced an invalid header, and non-ASCII names were
    lost because the header is written as ISO-8859-1.
    
    Build the header with Spring ContentDisposition instead: ASCII names are
    emitted verbatim, header-unsafe characters are replaced, and non-ASCII
    names additionally get an RFC 5987 filename* parameter so browsers keep
    the original characters.
    
    Fixes #2489
    
    * fix(dlq): expose export completeness metadata
    
    collectDeadLetters() already knows whether the scan hit the cap, failed
    queues, or both, but the export endpoint only returned the message array,
    so a truncated export of 5000 messages was indistinguishable from a
    complete one.
    
    exportMessages now returns a DLQExportResultVO carrying the messages plus
    truncated/failedQueueCount/limit. The controller serializes the message
    list as before (the JSON file stays a plain array) and reports the
    completeness metadata via X-DLQ-Export-Truncated / X-DLQ-Export-FailedQueues
    / X-DLQ-Export-Limit headers; the frontend surfaces a warning when the
    export may be incomplete.
    
    Fixes #2490
---
 .../studio/instance/dlq/DLQController.java         |  41 +++++++-
 .../{DLQProvider.java => DLQExportResultVO.java}   |  27 ++++--
 .../rocketmq/studio/instance/dlq/DLQProvider.java  |   4 +-
 .../studio/instance/dlq/DLQProviderStub.java       |   4 +-
 .../rocketmq/studio/instance/dlq/DLQService.java   |   4 +-
 .../provider/apache/RocketMQDLQProvider.java       |  12 ++-
 .../studio/instance/dlq/DLQControllerTest.java     | 104 ++++++++++++++++++---
 .../provider/apache/RocketMQDLQProviderTest.java   |  15 ++-
 web/src/api/dlq.test.ts                            |  35 ++++++-
 web/src/api/message.ts                             |  18 +++-
 web/src/pages/instance/__tests__/DLQPage.test.tsx  |  25 ++++-
 web/src/pages/instance/dlq.tsx                     |  10 +-
 web/src/services/messageService.ts                 |  10 +-
 13 files changed, 259 insertions(+), 50 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
index 7ac1b33cd..d5b69b379 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
@@ -23,6 +23,7 @@ import org.apache.rocketmq.studio.common.domain.Result;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.http.ContentDisposition;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
@@ -33,13 +34,17 @@ import 
org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
-import java.util.List;
+import java.nio.charset.StandardCharsets;
 
 @RestController
 @RequestMapping("/api/dlq")
 @RequiredArgsConstructor
 public class DLQController {
 
+    private static final String HEADER_EXPORT_TRUNCATED = 
"X-DLQ-Export-Truncated";
+    private static final String HEADER_EXPORT_FAILED_QUEUES = 
"X-DLQ-Export-FailedQueues";
+    private static final String HEADER_EXPORT_LIMIT = "X-DLQ-Export-Limit";
+
     private final DLQService dlqService;
     private final ObjectMapper objectMapper;
 
@@ -64,21 +69,47 @@ public class DLQController {
                                                     @RequestParam(required = 
false) Long startTime,
                                                     @RequestParam(required = 
false) Long endTime,
                                                     @RequestParam(required = 
false) Integer maxCount) {
-        List<DLQMessageVO> messages = dlqService.exportMessages(
+        DLQExportResultVO result = dlqService.exportMessages(
                 instanceId, groupName, startTime, endTime, maxCount);
         byte[] body;
         try {
-            body = objectMapper.writeValueAsBytes(messages);
+            body = objectMapper.writeValueAsBytes(result.getMessages());
         } catch (JsonProcessingException exception) {
             throw new BusinessException(500, "Failed to serialize DLQ export");
         }
+        String fileName = "dlq-" + sanitizeForFilename(groupName) + ".json";
+        ContentDisposition.Builder builder = ContentDisposition.attachment();
+        if (fileName.chars().allMatch(ch -> ch < 128)) {
+            builder.filename(fileName);
+        } else {
+            // Non-ASCII names need the RFC 5987 filename* parameter so 
browsers keep the
+            // original characters instead of the lossy ASCII fallback.
+            builder.filename(fileName, StandardCharsets.UTF_8);
+        }
+        ContentDisposition disposition = builder.build();
         return ResponseEntity.ok()
-                .header(HttpHeaders.CONTENT_DISPOSITION,
-                        "attachment; filename=\"dlq-" + groupName + ".json\"")
+                .header(HttpHeaders.CONTENT_DISPOSITION, 
disposition.toString())
+                .header(HEADER_EXPORT_TRUNCATED, 
String.valueOf(result.isTruncated()))
+                .header(HEADER_EXPORT_FAILED_QUEUES, 
String.valueOf(result.getFailedQueueCount()))
+                .header(HEADER_EXPORT_LIMIT, String.valueOf(result.getLimit()))
                 .contentType(MediaType.APPLICATION_JSON)
                 .body(body);
     }
 
+    /**
+     * Strips the characters that would break a quoted RFC 6266 header value 
or confuse
+     * file managers; the non-ASCII part is preserved and emitted as an RFC 
5987
+     * {@code filename*} parameter by Spring.
+     */
+    private static String sanitizeForFilename(String raw) {
+        StringBuilder cleaned = new StringBuilder(raw.length());
+        for (int i = 0; i < raw.length(); i++) {
+            char c = raw.charAt(i);
+            cleaned.append(c == '"' || c == '\\' || c < 0x20 || c == 0x7f ? 
'_' : c);
+        }
+        return cleaned.toString();
+    }
+
     private void requireRequest(DLQResendRequestDTO request) {
         if (request == null) {
             throw new BusinessException(400, "DLQ resend request is required");
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQExportResultVO.java
similarity index 59%
copy from 
server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQExportResultVO.java
index 974fdd83d..5b1a6bcba 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQExportResultVO.java
@@ -16,17 +16,26 @@
  */
 package org.apache.rocketmq.studio.instance.dlq;
 
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
 
 import java.util.List;
-import org.apache.rocketmq.studio.common.domain.PageResult;
 
-public interface DLQProvider {
-    List<DLQGroupVO> listDLQGroups(String instanceId);
-
-    PageResult<DLQGroupVO> listDLQGroups(String instanceId, String search, int 
page, int pageSize);
+/**
+ * A DLQ export: the exported messages plus scan-completeness metadata so 
callers can
+ * tell whether the export is a full snapshot or a bounded/partial one. The 
message
+ * list itself stays a plain array in the JSON file so existing consumers keep 
working.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DLQExportResultVO {
 
-    DLQResendResultVO resendMessages(String instanceId, String groupName, Long 
startTime, Long endTime,
-                                     String targetTopic);
-    List<DLQMessageVO> exportMessages(String instanceId, String groupName, 
Long startTime, Long endTime,
-                                      Integer maxCount);
+    private List<DLQMessageVO> messages;
+    private boolean truncated;
+    private int failedQueueCount;
+    private int limit;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
index 974fdd83d..2669f66a2 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
@@ -27,6 +27,6 @@ public interface DLQProvider {
 
     DLQResendResultVO resendMessages(String instanceId, String groupName, Long 
startTime, Long endTime,
                                      String targetTopic);
-    List<DLQMessageVO> exportMessages(String instanceId, String groupName, 
Long startTime, Long endTime,
-                                      Integer maxCount);
+    DLQExportResultVO exportMessages(String instanceId, String groupName, Long 
startTime, Long endTime,
+                                     Integer maxCount);
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
index 6d57d83e4..a6e209e00 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
@@ -52,8 +52,8 @@ public class DLQProviderStub implements DLQProvider {
     }
 
     @Override
-    public List<DLQMessageVO> exportMessages(String instanceId, String 
groupName, Long startTime, Long endTime,
-                                             Integer maxCount) {
+    public DLQExportResultVO exportMessages(String instanceId, String 
groupName, Long startTime, Long endTime,
+                                            Integer maxCount) {
         log.warn("DLQProviderStub.exportMessages called but no real DLQ 
provider is configured. group={}", groupName);
         throw unsupported();
     }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
index 0a95afae7..860300ebd 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
@@ -60,8 +60,8 @@ public class DLQService {
         return dlqProvider.resendMessages(instanceId, groupName, startTime, 
endTime, targetTopic);
     }
 
-    public List<DLQMessageVO> exportMessages(String instanceId, String 
groupName, Long startTime, Long endTime,
-                                             Integer maxCount) {
+    public DLQExportResultVO exportMessages(String instanceId, String 
groupName, Long startTime, Long endTime,
+                                            Integer maxCount) {
         requireApacheInstance(instanceId);
         validateResendRequest(groupName, startTime, endTime);
         log.info("Exporting DLQ messages: group={}, maxCount={}", groupName, 
maxCount);
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
index 2058a77a0..998939600 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
@@ -34,6 +34,7 @@ import 
org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.common.util.Pagination;
+import org.apache.rocketmq.studio.instance.dlq.DLQExportResultVO;
 import org.apache.rocketmq.studio.instance.dlq.DLQGroupVO;
 import org.apache.rocketmq.studio.instance.dlq.DLQMessageVO;
 import org.apache.rocketmq.studio.instance.dlq.DLQProvider;
@@ -227,14 +228,19 @@ public class RocketMQDLQProvider implements DLQProvider {
     }
 
     @Override
-    public List<DLQMessageVO> exportMessages(String instanceId, String 
groupName, Long startTime, Long endTime,
-                                             Integer maxCount) {
+    public DLQExportResultVO exportMessages(String instanceId, String 
groupName, Long startTime, Long endTime,
+                                            Integer maxCount) {
         String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
         long end = endTime != null ? endTime : System.currentTimeMillis();
         long begin = startTime != null ? startTime : end - ONE_HOUR_MILLIS;
         int cap = maxCount == null || maxCount <= 0 ? RESEND_HARD_CAP : 
Math.min(maxCount, RESEND_HARD_CAP);
         DeadLetterScanResult scanResult = collectDeadLetters(instanceId, 
dlqTopic, begin, end, cap);
-        return scanResult.messages().stream().map(this::toExportVO).toList();
+        return DLQExportResultVO.builder()
+                
.messages(scanResult.messages().stream().map(this::toExportVO).toList())
+                .truncated(scanResult.truncated())
+                .failedQueueCount(scanResult.failedQueueCount())
+                .limit(cap)
+                .build();
     }
 
     private DLQMessageVO toExportVO(MessageExt message) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
index caf8c5ab1..a005fcfd8 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.instance.dlq;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
 import 
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@@ -204,17 +205,22 @@ class DLQControllerTest {
     @Test
     void exportDLQMessagesShouldReturnJsonAttachment() throws Exception {
         when(dlqService.exportMessages(eq("instance-1"), eq("test-group"), 
isNull(), isNull(), isNull()))
-                .thenReturn(List.of(
-                        DLQMessageVO.builder()
-                                .msgId("msg-1")
-                                .topic("%DLQ%test-group")
-                                .queueId(0)
-                                .offset(5L)
-                                .storeTime(150L)
-                                .keys("key-a")
-                                .body("hello dlq")
-                                .bodyBase64("aGVsbG8gZGxx")
-                                .build()));
+                .thenReturn(DLQExportResultVO.builder()
+                        .messages(List.of(
+                                DLQMessageVO.builder()
+                                        .msgId("msg-1")
+                                        .topic("%DLQ%test-group")
+                                        .queueId(0)
+                                        .offset(5L)
+                                        .storeTime(150L)
+                                        .keys("key-a")
+                                        .body("hello dlq")
+                                        .bodyBase64("aGVsbG8gZGxx")
+                                        .build()))
+                        .truncated(false)
+                        .failedQueueCount(0)
+                        .limit(5000)
+                        .build());
 
         mockMvc.perform(get("/api/dlq/export")
                         .param("instanceId", "instance-1")
@@ -222,6 +228,9 @@ class DLQControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
                         "attachment; filename=\"dlq-test-group.json\""))
+                .andExpect(header().string("X-DLQ-Export-Truncated", "false"))
+                .andExpect(header().string("X-DLQ-Export-FailedQueues", "0"))
+                .andExpect(header().string("X-DLQ-Export-Limit", "5000"))
                 
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
                 .andExpect(jsonPath("$[0].msgId").value("msg-1"))
                 .andExpect(jsonPath("$[0].body").value("hello dlq"));
@@ -232,7 +241,12 @@ class DLQControllerTest {
     @Test
     void exportDLQMessagesShouldPassTimeRangeTest() throws Exception {
         when(dlqService.exportMessages(eq("instance-1"), eq("test-group"), 
eq(1000L), eq(2000L), eq(100)))
-                .thenReturn(List.of());
+                .thenReturn(DLQExportResultVO.builder()
+                        .messages(List.of())
+                        .truncated(false)
+                        .failedQueueCount(0)
+                        .limit(100)
+                        .build());
 
         mockMvc.perform(get("/api/dlq/export")
                         .param("instanceId", "instance-1")
@@ -243,8 +257,74 @@ class DLQControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
                         "attachment; filename=\"dlq-test-group.json\""))
+                .andExpect(header().string("X-DLQ-Export-Limit", "100"))
                 
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON));
 
         verify(dlqService).exportMessages(eq("instance-1"), eq("test-group"), 
eq(1000L), eq(2000L), eq(100));
     }
+
+    @Test
+    void exportDLQMessagesShouldExposeIncompleteScanMetadata() throws 
Exception {
+        when(dlqService.exportMessages(eq("instance-1"), eq("test-group"), 
isNull(), isNull(), isNull()))
+                .thenReturn(DLQExportResultVO.builder()
+                        .messages(List.of())
+                        .truncated(true)
+                        .failedQueueCount(2)
+                        .limit(5000)
+                        .build());
+
+        mockMvc.perform(get("/api/dlq/export")
+                        .param("instanceId", "instance-1")
+                        .param("groupName", "test-group"))
+                .andExpect(status().isOk())
+                .andExpect(header().string("X-DLQ-Export-Truncated", "true"))
+                .andExpect(header().string("X-DLQ-Export-FailedQueues", "2"))
+                .andExpect(header().string("X-DLQ-Export-Limit", "5000"))
+                .andExpect(jsonPath("$").isArray())
+                .andExpect(jsonPath("$").isEmpty());
+    }
+
+    @Test
+    void exportDLQMessagesShouldSanitizeHeaderUnsafeGroupNameCharacters() 
throws Exception {
+        when(dlqService.exportMessages(eq("instance-1"), eq("we\"ird\\group"), 
isNull(), isNull(), isNull()))
+                .thenReturn(DLQExportResultVO.builder()
+                        .messages(List.of())
+                        .truncated(false)
+                        .failedQueueCount(0)
+                        .limit(5000)
+                        .build());
+
+        mockMvc.perform(get("/api/dlq/export")
+                        .param("instanceId", "instance-1")
+                        .param("groupName", "we\"ird\\group"))
+                .andExpect(status().isOk())
+                .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
+                        "attachment; filename=\"dlq-we_ird_group.json\""));
+    }
+
+    @Test
+    void exportDLQMessagesShouldEmitRfc5987FilenameForNonAsciiGroupName() 
throws Exception {
+        // CJK group name written as unicode escapes because checkstyle 
rejects raw chinese characters.
+        String cjkGroup = "\u8BA2\u5355";
+        when(dlqService.exportMessages(eq("instance-1"), eq(cjkGroup), 
isNull(), isNull(), isNull()))
+                .thenReturn(DLQExportResultVO.builder()
+                        .messages(List.of())
+                        .truncated(false)
+                        .failedQueueCount(0)
+                        .limit(5000)
+                        .build());
+
+        mockMvc.perform(get("/api/dlq/export")
+                        .param("instanceId", "instance-1")
+                        .param("groupName", cjkGroup))
+                .andExpect(status().isOk())
+                .andExpect(result -> {
+                    String disposition = 
result.getResponse().getHeader(HttpHeaders.CONTENT_DISPOSITION);
+                    Assertions.assertNotNull(disposition);
+                    Assertions.assertTrue(
+                            disposition.matches(
+                                    "attachment; filename=\"[^\"]*\"; 
filename\\*=UTF-8''dlq-%E8%AE%A2%E5%8D%95\\.json"),
+                            "unexpected content disposition: " + disposition);
+                });
+    }
 }
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 c22b07036..3aef77a7c 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
@@ -34,6 +34,7 @@ import org.apache.rocketmq.studio.cluster.broker.MqClientPool;
 import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.instance.dlq.DLQExportResultVO;
 import org.apache.rocketmq.studio.instance.dlq.DLQGroupVO;
 import org.apache.rocketmq.studio.instance.dlq.DLQMessageVO;
 import org.apache.rocketmq.studio.ops.audit.AuditService;
@@ -513,11 +514,14 @@ class RocketMQDLQProviderTest {
         
when(pullConsumer.fetchSubscribeMessageQueues(dlqTopic)).thenReturn(Set.of(queue));
         when(pullConsumer.searchOffset(eq(queue), anyLong())).thenReturn(0L);
         when(pullConsumer.pull(eq(queue), eq("*"), eq(0L), 
eq(32))).thenReturn(pullResult);
-        List<DLQMessageVO> exported =
+        DLQExportResultVO exported =
                 provider.exportMessages("instance-a", "group-a", 100L, 200L, 
1000);
 
-        assertThat(exported).hasSize(1);
-        DLQMessageVO vo = exported.get(0);
+        assertThat(exported.isTruncated()).isFalse();
+        assertThat(exported.getFailedQueueCount()).isZero();
+        assertThat(exported.getLimit()).isEqualTo(1000);
+        assertThat(exported.getMessages()).hasSize(1);
+        DLQMessageVO vo = exported.getMessages().get(0);
         assertThat(vo.getMsgId()).isEqualTo("msg-1");
         assertThat(vo.getTopic()).isEqualTo(dlqTopic);
         assertThat(vo.getQueueId()).isEqualTo(0);
@@ -538,8 +542,9 @@ class RocketMQDLQProviderTest {
         when(pullConsumer.pull(eq(queue), eq("*"), eq(0L), eq(32)))
                 .thenReturn(new PullResult(PullStatus.NO_NEW_MSG, 1L, 0L, 0L, 
List.of()));
         // maxCount=0 falls back to the hard cap instead of failing; scan 
still completes.
-        List<DLQMessageVO> exported =
+        DLQExportResultVO exported =
                 provider.exportMessages("instance-a", "group-a", 100L, 200L, 
0);
-        assertThat(exported).isEmpty();
+        assertThat(exported.getMessages()).isEmpty();
+        assertThat(exported.getLimit()).isEqualTo(5000);
     }
 }
diff --git a/web/src/api/dlq.test.ts b/web/src/api/dlq.test.ts
index 343df8de9..0a2c8c464 100644
--- a/web/src/api/dlq.test.ts
+++ b/web/src/api/dlq.test.ts
@@ -18,7 +18,7 @@
 import MockAdapter from 'axios-mock-adapter';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import client from './client';
-import { listDLQGroups, resendDLQ } from './message';
+import { exportDLQMessages, listDLQGroups, resendDLQ } from './message';
 import type { DLQGroup } from './message';
 
 const mock = new MockAdapter(client);
@@ -89,4 +89,37 @@ describe('DLQ API', () => {
 
     await expect(resendDLQ(payload)).resolves.toEqual(result);
   });
+
+  it('returns the export blob with completeness metadata from the response 
headers', async () => {
+    const payload = new Blob(['[]'], { type: 'application/json' });
+    mock.onGet('/dlq/export').reply(200, payload, {
+      'content-type': 'application/json',
+      'content-disposition': 'attachment; filename="dlq-order-consumer.json"',
+      'x-dlq-export-truncated': 'true',
+      'x-dlq-export-failedqueues': '1',
+      'x-dlq-export-limit': '5000',
+    });
+
+    const { blob, meta } = await exportDLQMessages({
+      instanceId: 'instance-1',
+      groupName: group.groupName,
+    });
+
+    await expect(blob.text()).resolves.toBe('[]');
+    expect(meta).toEqual({ truncated: true, failedQueueCount: 1, limit: 5000 
});
+  });
+
+  it('defaults the export metadata when the backend omits the headers', async 
() => {
+    const payload = new Blob(['[]'], { type: 'application/json' });
+    mock.onGet('/dlq/export').reply(200, payload, {
+      'content-type': 'application/json',
+    });
+
+    const { meta } = await exportDLQMessages({
+      instanceId: 'instance-1',
+      groupName: group.groupName,
+    });
+
+    expect(meta).toEqual({ truncated: false, failedQueueCount: 0, limit: 0 });
+  });
 });
diff --git a/web/src/api/message.ts b/web/src/api/message.ts
index 27fa8b10e..e5b705674 100644
--- a/web/src/api/message.ts
+++ b/web/src/api/message.ts
@@ -135,15 +135,29 @@ export async function resendDLQ(data: {
   return res.data.data;
 }
 
+export interface DLQExportMeta {
+  truncated: boolean;
+  failedQueueCount: number;
+  limit: number;
+}
+
 export async function exportDLQMessages(params: {
   instanceId: string;
   groupName: string;
   startTime?: number;
   endTime?: number;
   maxCount?: number;
-}): Promise<Blob> {
+}): Promise<{ blob: Blob; meta: DLQExportMeta }> {
   const res = await client.get<Blob>('/dlq/export', { params, responseType: 
'blob' });
-  return res.data;
+  const header = (name: string): string => String(res.headers[name] ?? '');
+  return {
+    blob: res.data,
+    meta: {
+      truncated: header('x-dlq-export-truncated') === 'true',
+      failedQueueCount: Number.parseInt(header('x-dlq-export-failedqueues'), 
10) || 0,
+      limit: Number.parseInt(header('x-dlq-export-limit'), 10) || 0,
+    },
+  };
 }
 
 // ─── Queue Browser ─────────────────────────────────────────────────
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx 
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index c311e8ee4..02a578a1e 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -241,11 +241,12 @@ describe('DLQ page', () => {
   });
 
   it('exports the dead-letter messages of a group as JSON', async () => {
-    vi.mocked(messageService.exportDLQMessages).mockResolvedValue(
-      new 
Blob(['[{"msgId":"m1","topic":"%DLQ%cg-order","queueId":0,"offset":5}]'], {
+    vi.mocked(messageService.exportDLQMessages).mockResolvedValue({
+      blob: new 
Blob(['[{"msgId":"m1","topic":"%DLQ%cg-order","queueId":0,"offset":5}]'], {
         type: 'application/json',
       }),
-    );
+      meta: { truncated: false, failedQueueCount: 0, limit: 5000 },
+    });
     const user = userEvent.setup();
     renderWithProviders(<DLQPage />);
 
@@ -267,6 +268,24 @@ describe('DLQ page', () => {
     expect(revokeObjectURL).toHaveBeenCalledWith('blob:dlq');
   });
 
+  it('warns when the export scan is incomplete', async () => {
+    vi.mocked(messageService.exportDLQMessages).mockResolvedValue({
+      blob: new Blob(['[]'], { type: 'application/json' }),
+      meta: { truncated: true, failedQueueCount: 2, limit: 100 },
+    });
+    const user = userEvent.setup();
+    renderWithProviders(<DLQPage />);
+
+    await screen.findByText('cg-order');
+    await user.click(screen.getByRole('button', { name: '导出' }));
+
+    await waitFor(() =>
+      expect(
+        screen.getByText('导出可能不完整:2 个队列无法扫描,导出上限 100 条'),
+      ).toBeInTheDocument(),
+    );
+  });
+
   it('exports summaries for the selected groups in one CSV file', async () => {
     
vi.mocked(messageService.listDLQGroups).mockResolvedValue(pageOf([dlqGroup, 
secondDlqGroup]));
     const user = userEvent.setup();
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index ffbc55858..d18319d97 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -266,14 +266,20 @@ const DLQPage = () => {
 
   const handleExport = async (group: DLQGroup) => {
     try {
-      const blob = await exportDLQMessages({
+      const { blob, meta } = await exportDLQMessages({
         instanceId: selectedInstanceId,
         groupName: group.groupName,
         startTime: exportRange[0].valueOf(),
         endTime: exportRange[1].valueOf(),
       });
       downloadBlob(blob, `${group.groupName}-dlq-messages.json`);
-      message.success(`已导出 ${group.groupName} 的死信消息(${blob.size} 字节)`);
+      if (meta.truncated || meta.failedQueueCount > 0) {
+        message.warning(
+          `导出可能不完整:${meta.failedQueueCount} 个队列无法扫描,导出上限 ${meta.limit} 条`,
+        );
+      } else {
+        message.success(`已导出 ${group.groupName} 的死信消息(${blob.size} 字节)`);
+      }
     } catch (error) {
       message.error(getErrorMessage(error, '导出死信消息失败,请稍后重试'));
     }
diff --git a/web/src/services/messageService.ts 
b/web/src/services/messageService.ts
index ff9e0de50..776fc3a64 100644
--- a/web/src/services/messageService.ts
+++ b/web/src/services/messageService.ts
@@ -9,6 +9,7 @@ import type {
   DLQGroup,
   DLQGroupPage,
   DLQResendResult,
+  DLQExportMeta,
 } from '../api/message';
 import { mockMessages, mockMessageTraces } from '../mock/messages';
 import { mockDLQGroups } from '../mock/dlq';
@@ -119,7 +120,12 @@ export async function exportDLQMessages(params: {
   startTime?: number;
   endTime?: number;
   maxCount?: number;
-}): Promise<Blob> {
-  if (isMockMode()) return new Blob(['[]'], { type: 'application/json' });
+}): Promise<{ blob: Blob; meta: DLQExportMeta }> {
+  if (isMockMode()) {
+    return {
+      blob: new Blob(['[]'], { type: 'application/json' }),
+      meta: { truncated: false, failedQueueCount: 0, limit: 5000 },
+    };
+  }
   return messageApi.exportDLQMessages(params);
 }

Reply via email to