mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 00:09:41 +08:00
## 背景 导出查询结果时,时间字段在部分场景出现错误时区偏移。典型表现为数据库中正确的本地时间在导出后被额外偏移(例如 +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" />
353 lines
12 KiB
Go
353 lines
12 KiB
Go
package app
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"GoNavi-Wails/internal/connection"
|
||
)
|
||
|
||
type fakeExportQueryDB struct {
|
||
data []map[string]interface{}
|
||
cols []string
|
||
err error
|
||
|
||
lastQuery string
|
||
lastContextTimeout time.Duration
|
||
hasContextDeadline bool
|
||
}
|
||
|
||
func (f *fakeExportQueryDB) Connect(config connection.ConnectionConfig) error { return nil }
|
||
func (f *fakeExportQueryDB) Close() error { return nil }
|
||
func (f *fakeExportQueryDB) Ping() error { return nil }
|
||
func (f *fakeExportQueryDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||
f.lastQuery = query
|
||
return f.data, f.cols, f.err
|
||
}
|
||
func (f *fakeExportQueryDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||
f.lastQuery = query
|
||
if deadline, ok := ctx.Deadline(); ok {
|
||
f.hasContextDeadline = true
|
||
f.lastContextTimeout = time.Until(deadline)
|
||
}
|
||
return f.data, f.cols, f.err
|
||
}
|
||
func (f *fakeExportQueryDB) Exec(query string) (int64, error) { return 0, nil }
|
||
func (f *fakeExportQueryDB) GetDatabases() ([]string, error) { return nil, nil }
|
||
func (f *fakeExportQueryDB) GetTables(dbName string) ([]string, error) {
|
||
return nil, nil
|
||
}
|
||
func (f *fakeExportQueryDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||
return "", nil
|
||
}
|
||
func (f *fakeExportQueryDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||
return nil, nil
|
||
}
|
||
func (f *fakeExportQueryDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||
return nil, nil
|
||
}
|
||
func (f *fakeExportQueryDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||
return nil, nil
|
||
}
|
||
func (f *fakeExportQueryDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||
return nil, nil
|
||
}
|
||
func (f *fakeExportQueryDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func TestFormatExportCellText_FloatNoScientificNotation(t *testing.T) {
|
||
got := formatExportCellText(1.445663e+06)
|
||
if strings.Contains(strings.ToLower(got), "e+") || strings.Contains(strings.ToLower(got), "e-") {
|
||
t.Fatalf("不应输出科学计数法,got=%q", got)
|
||
}
|
||
if got != "1445663" {
|
||
t.Fatalf("浮点整值导出异常,want=%q got=%q", "1445663", got)
|
||
}
|
||
}
|
||
|
||
func TestWriteRowsToFile_Markdown_NumberKeepPlainText(t *testing.T) {
|
||
f, err := os.CreateTemp("", "gonavi-export-*.md")
|
||
if err != nil {
|
||
t.Fatalf("创建临时文件失败: %v", err)
|
||
}
|
||
defer os.Remove(f.Name())
|
||
defer f.Close()
|
||
|
||
data := []map[string]interface{}{
|
||
{"id": 1.445663e+06},
|
||
}
|
||
columns := []string{"id"}
|
||
|
||
if err := writeRowsToFile(f, data, columns, "md"); err != nil {
|
||
t.Fatalf("写入 md 失败: %v", err)
|
||
}
|
||
|
||
contentBytes, err := os.ReadFile(f.Name())
|
||
if err != nil {
|
||
t.Fatalf("读取 md 失败: %v", err)
|
||
}
|
||
content := string(contentBytes)
|
||
if strings.Contains(strings.ToLower(content), "e+") || strings.Contains(strings.ToLower(content), "e-") {
|
||
t.Fatalf("md 导出包含科学计数法: %s", content)
|
||
}
|
||
if !strings.Contains(content, "| 1445663 |") {
|
||
t.Fatalf("md 导出未保留整数字面量,content=%s", content)
|
||
}
|
||
}
|
||
|
||
func TestWriteRowsToFile_JSON_NumberKeepPlainText(t *testing.T) {
|
||
f, err := os.CreateTemp("", "gonavi-export-*.json")
|
||
if err != nil {
|
||
t.Fatalf("创建临时文件失败: %v", err)
|
||
}
|
||
defer os.Remove(f.Name())
|
||
defer f.Close()
|
||
|
||
data := []map[string]interface{}{
|
||
{"id": 1.445663e+06},
|
||
}
|
||
columns := []string{"id"}
|
||
|
||
if err := writeRowsToFile(f, data, columns, "json"); err != nil {
|
||
t.Fatalf("写入 json 失败: %v", err)
|
||
}
|
||
|
||
contentBytes, err := os.ReadFile(f.Name())
|
||
if err != nil {
|
||
t.Fatalf("读取 json 失败: %v", err)
|
||
}
|
||
content := string(contentBytes)
|
||
if strings.Contains(strings.ToLower(content), "e+") || strings.Contains(strings.ToLower(content), "e-") {
|
||
t.Fatalf("json 导出包含科学计数法: %s", content)
|
||
}
|
||
|
||
var decoded []map[string]json.Number
|
||
decoder := json.NewDecoder(bytes.NewReader(contentBytes))
|
||
decoder.UseNumber()
|
||
if err := decoder.Decode(&decoded); err != nil {
|
||
t.Fatalf("解析导出 json 失败: %v", err)
|
||
}
|
||
if len(decoded) != 1 {
|
||
t.Fatalf("导出行数异常,got=%d", len(decoded))
|
||
}
|
||
if decoded[0]["id"].String() != "1445663" {
|
||
t.Fatalf("json 数值格式异常,want=1445663 got=%s", decoded[0]["id"].String())
|
||
}
|
||
}
|
||
|
||
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}},
|
||
cols: []string{"v"},
|
||
}
|
||
_, _, err := queryDataForExport(fake, connection.ConnectionConfig{Timeout: 10}, "SELECT 1")
|
||
if err != nil {
|
||
t.Fatalf("queryDataForExport 返回错误: %v", err)
|
||
}
|
||
if !fake.hasContextDeadline {
|
||
t.Fatal("queryDataForExport 应设置 context deadline")
|
||
}
|
||
if fake.lastQuery != "SELECT 1" {
|
||
t.Fatalf("queryDataForExport 查询语句异常,want=%q got=%q", "SELECT 1", fake.lastQuery)
|
||
}
|
||
lowerBound := minExportQueryTimeout - 5*time.Second
|
||
upperBound := minExportQueryTimeout + 5*time.Second
|
||
if fake.lastContextTimeout < lowerBound || fake.lastContextTimeout > upperBound {
|
||
t.Fatalf("导出最小超时异常,want≈%s got=%s", minExportQueryTimeout, fake.lastContextTimeout)
|
||
}
|
||
}
|
||
|
||
func TestQueryDataForExport_UsesLargerConfiguredTimeout(t *testing.T) {
|
||
fake := &fakeExportQueryDB{
|
||
data: []map[string]interface{}{{"v": 1}},
|
||
cols: []string{"v"},
|
||
}
|
||
_, _, err := queryDataForExport(fake, connection.ConnectionConfig{Timeout: 900}, "SELECT 1")
|
||
if err != nil {
|
||
t.Fatalf("queryDataForExport 返回错误: %v", err)
|
||
}
|
||
if !fake.hasContextDeadline {
|
||
t.Fatal("queryDataForExport 应设置 context deadline")
|
||
}
|
||
expected := 900 * time.Second
|
||
lowerBound := expected - 5*time.Second
|
||
upperBound := expected + 5*time.Second
|
||
if fake.lastContextTimeout < lowerBound || fake.lastContextTimeout > upperBound {
|
||
t.Fatalf("导出配置超时异常,want≈%s got=%s", expected, fake.lastContextTimeout)
|
||
}
|
||
}
|
||
|
||
func TestGetExportQueryTimeout_ClickHouseUsesLongerMinimum(t *testing.T) {
|
||
timeout := getExportQueryTimeout(connection.ConnectionConfig{
|
||
Type: "clickhouse",
|
||
Timeout: 30,
|
||
})
|
||
if timeout != minClickHouseExportQueryTimeout {
|
||
t.Fatalf("clickhouse 导出超时下限异常,want=%s got=%s", minClickHouseExportQueryTimeout, timeout)
|
||
}
|
||
}
|
||
|
||
func TestGetExportQueryTimeout_CustomClickHouseUsesLongerMinimum(t *testing.T) {
|
||
timeout := getExportQueryTimeout(connection.ConnectionConfig{
|
||
Type: "custom",
|
||
Driver: "clickhouse",
|
||
Timeout: 30,
|
||
})
|
||
if timeout != minClickHouseExportQueryTimeout {
|
||
t.Fatalf("custom clickhouse 导出超时下限异常,want=%s got=%s", minClickHouseExportQueryTimeout, timeout)
|
||
}
|
||
}
|
||
|
||
func TestWriteRowsToFile_HTML_EscapeAndStyle(t *testing.T) {
|
||
f, err := os.CreateTemp("", "gonavi-export-*.html")
|
||
if err != nil {
|
||
t.Fatalf("创建临时文件失败: %v", err)
|
||
}
|
||
defer os.Remove(f.Name())
|
||
defer f.Close()
|
||
|
||
data := []map[string]interface{}{
|
||
{
|
||
"name": "<script>alert(1)</script>",
|
||
"note": "line1\nline2",
|
||
"nullable": nil,
|
||
},
|
||
}
|
||
columns := []string{"name", "note", "nullable"}
|
||
|
||
if err := writeRowsToFile(f, data, columns, "html"); err != nil {
|
||
t.Fatalf("写入 html 失败: %v", err)
|
||
}
|
||
|
||
contentBytes, err := os.ReadFile(f.Name())
|
||
if err != nil {
|
||
t.Fatalf("读取 html 失败: %v", err)
|
||
}
|
||
content := string(contentBytes)
|
||
|
||
if !strings.Contains(content, "<!DOCTYPE html>") {
|
||
t.Fatalf("html 导出缺少 doctype: %s", content)
|
||
}
|
||
if !strings.Contains(content, "position: sticky") {
|
||
t.Fatalf("html 导出缺少表头吸顶样式: %s", content)
|
||
}
|
||
if !strings.Contains(content, "tbody tr:nth-child(even)") {
|
||
t.Fatalf("html 导出缺少斑马纹样式: %s", content)
|
||
}
|
||
if !strings.Contains(content, "<script>alert(1)</script>") {
|
||
t.Fatalf("html 导出未进行 XSS 转义: %s", content)
|
||
}
|
||
if strings.Contains(content, "<script>alert(1)</script>") {
|
||
t.Fatalf("html 导出包含未转义脚本: %s", content)
|
||
}
|
||
if !strings.Contains(content, "line1<br>line2") {
|
||
t.Fatalf("html 导出换行未转为 <br>: %s", content)
|
||
}
|
||
if !strings.Contains(content, "<td>NULL</td>") {
|
||
t.Fatalf("html 导出空值显示异常: %s", content)
|
||
}
|
||
}
|
||
|
||
func TestWriteRowsToFile_HTML_EscapeHeader(t *testing.T) {
|
||
f, err := os.CreateTemp("", "gonavi-export-*.html")
|
||
if err != nil {
|
||
t.Fatalf("创建临时文件失败: %v", err)
|
||
}
|
||
defer os.Remove(f.Name())
|
||
defer f.Close()
|
||
|
||
columnName := "<b>name</b>"
|
||
data := []map[string]interface{}{{columnName: "ok"}}
|
||
if err := writeRowsToFile(f, data, []string{columnName}, "html"); err != nil {
|
||
t.Fatalf("写入 html 失败: %v", err)
|
||
}
|
||
contentBytes, _ := os.ReadFile(f.Name())
|
||
content := string(contentBytes)
|
||
if !strings.Contains(content, "<th><b>name</b></th>") || strings.Contains(content, "<th><b>name</b></th>") {
|
||
t.Fatalf("html 表头未正确转义: %s", content)
|
||
}
|
||
}
|
||
|
||
func TestFormatImportSQLValue_NormalizesTimestampWithoutTimezone(t *testing.T) {
|
||
got := formatImportSQLValue("postgres", "timestamp without time zone", "2026-01-21T18:32:26+08:00")
|
||
if got != "'2026-01-21 18:32:26'" {
|
||
t.Fatalf("时间字面量归一化异常,want=%q got=%q", "'2026-01-21 18:32:26'", got)
|
||
}
|
||
}
|
||
|
||
func TestFormatImportSQLValue_LeavesTextLiteralUntouched(t *testing.T) {
|
||
got := formatImportSQLValue("postgres", "text", "2026-01-21T18:32:26+08:00")
|
||
if got != "'2026-01-21T18:32:26+08:00'" {
|
||
t.Fatalf("文本字段不应被归一化,want=%q got=%q", "'2026-01-21T18:32:26+08:00'", got)
|
||
}
|
||
}
|