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 99603cf4b fix(audit): label the exported timestamp column with the
server zone (#3995)
99603cf4b is described below
commit 99603cf4b2a76887f8af9632fb7b98b88b23ff4a
Author: Zhao Jianing <[email protected]>
AuthorDate: Tue Sep 15 19:23:44 2026 +0800
fix(audit): label the exported timestamp column with the server zone (#3995)
Audit timestamps are stored as zone-less server-local values — whatever
zone the server JVM and the MySQL session run in (docker-compose.yml
sets TZ=Asia/Shanghai) — while the alert subsystem formats its
timestamps in UTC. The audit CSV export therefore shipped a bare
'timestamp' header, and a consumer could not tell which zone the values
were in; the natural assumption (UTC, matching the alerting side) is 8
hours off on the documented deployment.
Name the exported column after the server's UTC offset — e.g.
timestamp(UTC+08:00) — computed from the JVM default zone at export
time, so the file is interpretable without out-of-band knowledge. The
stored base is deliberately not converted: switching it to UTC is a
repo-wide change that needs schema defaults and a backfill plan.
Signed-off-by: zjncs <[email protected]>
---
.../rocketmq/studio/ops/audit/AuditService.java | 24 ++++++++++++++--
.../studio/ops/audit/AuditServiceTest.java | 32 ++++++++++++++++++++++
2 files changed, 53 insertions(+), 3 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
index 881a0e09e..734b6839c 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
@@ -27,6 +27,8 @@ import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
@@ -39,8 +41,8 @@ public class AuditService {
private static final int MAX_EXPORT_RECORDS = 10_000;
private static final int CLEANUP_BATCH_SIZE = 500;
private static final int CLEANUP_MAX_BATCHES = 20;
- private static final String CSV_HEADER =
-
"timestamp,operator,operationType,resourceType,target,clusterId,detail,result,errorMessage\r\n";
+ private static final String CSV_COLUMNS =
+
"operator,operationType,resourceType,target,clusterId,detail,result,errorMessage";
private final AuditRepository auditRepository;
@@ -77,7 +79,7 @@ public class AuditService {
throw new BusinessException(400,
"Audit log export exceeds the maximum of " +
MAX_EXPORT_RECORDS + " records; narrow the filters");
}
- StringBuilder csv = new StringBuilder("\uFEFF").append(CSV_HEADER);
+ StringBuilder csv = new StringBuilder("\uFEFF").append(csvHeader());
for (AuditRecordVO record : page.getItems()) {
CsvUtil.appendRow(csv,
record.getTimestamp(),
@@ -93,6 +95,22 @@ public class AuditService {
return csv.toString();
}
+ /**
+ * Audit timestamps are stored as zone-less server-local values (whatever
zone the server
+ * JVM/MySQL session runs in), so the exported timestamp column names the
server's UTC
+ * offset — e.g. {@code timestamp(UTC+08:00)} — making the file
interpretable without
+ * out-of-band timezone knowledge. The stored base itself is deliberately
not converted;
+ * switching it to UTC is a repo-wide change that needs schema defaults
and a backfill.
+ */
+ private String csvHeader() {
+ return "timestamp(" + serverZoneLabel() + ")," + CSV_COLUMNS + "\r\n";
+ }
+
+ private String serverZoneLabel() {
+ ZoneOffset offset = OffsetDateTime.now().getOffset();
+ return "UTC" + (offset.getTotalSeconds() == 0 ? "" : offset.getId());
+ }
+
public void record(String operationType, String target, String detail,
String result) {
record(operationType, target, null, detail, result);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
index acba84d26..eaa5972d1 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
@@ -29,6 +29,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
+import java.util.TimeZone;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -163,6 +164,37 @@ class AuditServiceTest {
.contains("\"'=denied\"");
}
+ @Test
+ void exportLogsLabelsTimestampColumnWithServerZoneOffset() {
+ AuditRecordVO record = AuditRecordVO.builder()
+ .timestamp(LocalDateTime.of(2026, 8, 1, 9, 30))
+ .operator("alice")
+ .operationType("DELETE")
+ .resourceType("TOPIC")
+ .target("topic-a")
+ .result("SUCCESS")
+ .build();
+ when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(),
+ any(LocalDateTime.class), any(LocalDateTime.class), isNull(),
eq(1), eq(10_000)))
+ .thenReturn(PageResult.of(List.of(record), 1, 1, 10_000));
+
+ TimeZone originalZone = TimeZone.getDefault();
+ try {
+ // The stored base is server-local and stays unconverted; only the
column name
+ // tells the consumer which zone the values are in.
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+ assertThat(auditService.exportLogs(null, null, null, null,
"2026-08-01", "2026-08-02", null))
+ .startsWith("\uFEFFtimestamp(UTC+08:00),operator,")
+ .contains("\"2026-08-01T09:30\"");
+
+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
+ assertThat(auditService.exportLogs(null, null, null, null,
"2026-08-01", "2026-08-02", null))
+ .startsWith("\uFEFFtimestamp(UTC),operator,");
+ } finally {
+ TimeZone.setDefault(originalZone);
+ }
+ }
+
@Test
void exportLogsRejectsResultsBeyondBound() {
when(auditRepository.findPage(isNull(), isNull(), isNull(), isNull(),
isNull(), isNull(),