🐛 fix(export): 修复导出时间时区误偏移 (#345)

## 背景
导出查询结果时,时间字段在部分场景出现错误时区偏移。典型表现为数据库中正确的本地时间在导出后被额外偏移(例如 +8 小时),影响
JSON/文本类导出的可用性与可信度。

## 变更内容
- 修复导出时间解析逻辑,区分“带时区时间字符串”和“无时区时间字符串”的处理方式:
  - 带时区值按其时区语义解析;
  - 无时区值按本地语义解析,避免误按 UTC 导致二次偏移。
- 统一导出时间格式化行为,避免在导出阶段再次进行不必要的时区换算,确保 `timestamp without time zone`
等场景保持原始钟表时间。
- 补充回归测试,覆盖以下关键路径:
  - 无时区时间字符串导出不偏移;
  - RFC3339 字符串解析后格式化行为稳定;
  - `time.Time` 导出保持预期钟表时间;
  - JSON 导出时间字段行为一致。

## 影响范围
- 主要影响导出链路中的时间字段格式化(CSV/JSON/MD/HTML/XLSX 对应后端写出逻辑)。
- 不涉及连接协议、SQL 执行流程和驱动安装机制。

## 验证方式
- 已通过:
  - `go test ./internal/app`
  - `go test -race ./internal/app`
  - `go test ./...`

## 风险与说明
- 已确认并修复本次问题对应的导出时区偏移路径。
- 当前系统仍存在“基于值推断时间语义”的历史设计约束;这里的“元数据驱动”是指基于数据库列定义类型(如 `timestamp
with/without time zone`、`datetimeoffset` 等)来决定是否允许时区换算。
- 上述历史约束并非本次修改引入。后续建议按数据库类型矩阵(DB matrix)逐库适配元数据策略,以降低跨数据库兼容风险与误判风险。

## 相关截图
- 问题对比:问题1、问题2
<img width="419" height="170" alt="问题1"
src="https://github.com/user-attachments/assets/a4d9f949-1f5c-4dcc-b3fa-13082347fec3"
/>
<img width="736" height="130" alt="问题2"
src="https://github.com/user-attachments/assets/b1d5b9e4-7f79-4929-875c-a422d1fbe51b"
/>

---
- 修复后:修复1、修复2
<img width="548" height="130" alt="修复1"
src="https://github.com/user-attachments/assets/1ee0a91d-2dec-4060-9c8e-9817f437dae7"
/>
<img width="486" height="128" alt="修复2"
src="https://github.com/user-attachments/assets/baa8cb25-b08a-4f31-94d8-a4a50753fb97"
/>
This commit is contained in:
Syngnat
2026-04-08 10:23:27 +08:00
committed by GitHub
2 changed files with 81 additions and 8 deletions

View File

@@ -614,13 +614,23 @@ func parseTemporalString(raw string) (time.Time, bool) {
return time.Time{}, false
}
layouts := []string{
layoutsWithZone := []string{
"2006-01-02 15:04:05.999999999 -0700 MST",
"2006-01-02 15:04:05 -0700 MST",
"2006-01-02 15:04:05.999999999 -0700",
"2006-01-02 15:04:05 -0700",
time.RFC3339Nano,
time.RFC3339,
}
for _, layout := range layoutsWithZone {
parsed, err := time.Parse(layout, text)
if err == nil {
return parsed, true
}
}
layoutsWithoutZone := []string{
"2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05",
"2006-01-02",
@@ -628,8 +638,8 @@ func parseTemporalString(raw string) (time.Time, bool) {
"15:04:05",
}
for _, layout := range layouts {
parsed, err := time.Parse(layout, text)
for _, layout := range layoutsWithoutZone {
parsed, err := time.ParseInLocation(layout, text, time.Local)
if err == nil {
return parsed, true
}
@@ -2212,9 +2222,9 @@ func formatExportCellText(val interface{}) string {
return text
default:
text := fmt.Sprintf("%v", val)
// 字符串型日期时间值(如 RFC3339 "2026-03-10T17:01:55+08:00")格式化为本地时区 yyyy-MM-dd HH:mm:ss
// 字符串型日期时间值(如 RFC3339 "2026-03-10T17:01:55+08:00"统一格式化为 yyyy-MM-dd HH:mm:ss
if parsed, ok := parseTemporalString(text); ok {
return parsed.Local().Format("2006-01-02 15:04:05")
return parsed.Format("2006-01-02 15:04:05")
}
return text
}
@@ -2227,15 +2237,15 @@ func normalizeExportJSONValue(val interface{}) interface{} {
switch v := val.(type) {
case time.Time:
return v.Local().Format("2006-01-02 15:04:05")
return v.Format("2006-01-02 15:04:05")
case *time.Time:
if v == nil {
return nil
}
return v.Local().Format("2006-01-02 15:04:05")
return v.Format("2006-01-02 15:04:05")
case string:
if parsed, ok := parseTemporalString(v); ok {
return parsed.Local().Format("2006-01-02 15:04:05")
return parsed.Format("2006-01-02 15:04:05")
}
return v
case float32:

View File

@@ -141,6 +141,69 @@ func TestWriteRowsToFile_JSON_NumberKeepPlainText(t *testing.T) {
}
}
func TestNormalizeExportJSONValue_LocalDateTimeString_NoTimezoneShift(t *testing.T) {
originalLocal := time.Local
time.Local = time.FixedZone("UTC+8", 8*60*60)
defer func() { time.Local = originalLocal }()
got := normalizeExportJSONValue("2026-04-07 18:44:32")
if got != "2026-04-07 18:44:32" {
t.Fatalf("本地无时区字符串不应发生时区偏移want=%q got=%v", "2026-04-07 18:44:32", got)
}
}
func TestFormatExportCellText_TimeValue_KeepWallClock(t *testing.T) {
originalLocal := time.Local
time.Local = time.FixedZone("UTC+8", 8*60*60)
defer func() { time.Local = originalLocal }()
utc := time.Date(2026, 4, 7, 10, 44, 32, 0, time.UTC)
got := formatExportCellText(utc)
if got != "2026-04-07 10:44:32" {
t.Fatalf("time.Time 导出应保持原始钟表时间want=%q got=%q", "2026-04-07 10:44:32", got)
}
}
func TestParseTemporalString_LocalDateTime_NoTimezoneShift(t *testing.T) {
originalLocal := time.Local
time.Local = time.FixedZone("UTC+8", 8*60*60)
defer func() { time.Local = originalLocal }()
parsed, ok := parseTemporalString("2026-04-07 18:44:32")
if !ok {
t.Fatal("parseTemporalString 应成功解析本地日期时间")
}
if parsed.Local().Format("2006-01-02 15:04:05") != "2026-04-07 18:44:32" {
t.Fatalf("无时区时间解析后不应发生偏移got=%q", parsed.Local().Format("2006-01-02 15:04:05"))
}
}
func TestParseTemporalString_RFC3339_KeepWallClock(t *testing.T) {
originalLocal := time.Local
time.Local = time.FixedZone("UTC+8", 8*60*60)
defer func() { time.Local = originalLocal }()
parsed, ok := parseTemporalString("2026-04-07T10:44:32Z")
if !ok {
t.Fatal("parseTemporalString 应成功解析 RFC3339")
}
if parsed.Format("2006-01-02 15:04:05") != "2026-04-07 10:44:32" {
t.Fatalf("RFC3339 解析后应保持原始钟表时间got=%q", parsed.Format("2006-01-02 15:04:05"))
}
}
func TestNormalizeExportJSONValue_TimeValue_KeepWallClock(t *testing.T) {
originalLocal := time.Local
time.Local = time.FixedZone("UTC+8", 8*60*60)
defer func() { time.Local = originalLocal }()
utc := time.Date(2026, 4, 7, 18, 44, 32, 0, time.UTC)
got := normalizeExportJSONValue(utc)
if got != "2026-04-07 18:44:32" {
t.Fatalf("JSON 导出 time.Time 应保持原始钟表时间want=%q got=%v", "2026-04-07 18:44:32", got)
}
}
func TestQueryDataForExport_UsesMinimumTimeout(t *testing.T) {
fake := &fakeExportQueryDB{
data: []map[string]interface{}{{"v": 1}},