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(),

Reply via email to