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