// 时区固定验收(team-lead 口径):进程 Asia/Shanghai + DSN loc 一致 + JSON 恒 `+08:00`。 // // 两条验收判据(缺一即视为未完成): // 1. 含 `time.Time` 的响应 JSON 匹配 `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?\+08:00$`; // 2. **改机器/容器时区也不变**——本文件用 `TZ=UTC` / `TZ=America/New_York` 复现"CI 容器/异地部署", // 断言偏移仍恒为 `+08:00`(这正是修复前会真实错 8 小时的场景)。 package tz import ( "encoding/json" "regexp" "strings" "testing" "time" ) // rfc3339Plus8 契约要求的 JSON 时间形状(RFC3339 + 固定 +08:00 偏移)。 var rfc3339Plus8 = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?\+08:00$`) // TestApplyFixesProcessZone Apply 后进程时区恒为 Asia/Shanghai,且不受机器 TZ 影响。 // // 说明:`TZ` 只在进程启动时影响 `time.Local` 的初值,运行期改 `TZ` 不会自动生效 —— // 这里刻意先改 `TZ` 再 `Apply()`,模拟"容器把 TZ 设成 UTC 后进程启动/重设时区"的最坏情况。 func TestApplyFixesProcessZone(t *testing.T) { for _, machineTZ := range []string{"UTC", "America/New_York", "Europe/Berlin"} { t.Run("TZ="+machineTZ, func(t *testing.T) { t.Setenv("TZ", machineTZ) // 模拟 CI 容器/异地机器 original := time.Local t.Cleanup(func() { time.Local = original }) loc := Apply() if got := time.Local.String(); got != LocationName { t.Fatalf("Apply 后 time.Local = %q,期望 %q(不能跟随机器 TZ=%s)", got, LocationName, machineTZ) } if loc.String() != LocationName { t.Fatalf("Apply 返回值 = %q,期望 %q", loc.String(), LocationName) } // 偏移必须是 +08:00(28800 秒) if _, offset := time.Now().In(loc).Zone(); offset != fixedZoneOffset { t.Fatalf("TZ=%s 下时区偏移 = %d 秒,期望 %d(+08:00)", machineTZ, offset, fixedZoneOffset) } }) } } // TestApplyIdempotent 重复 Apply 无副作用(多处装配/多单测共享时安全)。 func TestApplyIdempotent(t *testing.T) { original := time.Local t.Cleanup(func() { time.Local = original }) first := Apply() second := Apply() if first.String() != second.String() || second.String() != LocationName { t.Fatalf("Apply 不幂等:%q / %q", first, second) } } // TestJSONOffsetAlwaysPlus8 验收判据①:含 time.Time 的 JSON 恒带 `+08:00`。 // // 覆盖两条真实路径: // - `time.Now()`(未显式指定时区 → 取 time.Local,即 Apply 后的 Asia/Shanghai); // - 从 DB 读出的时间经 `In(tz.Location())` 归一后序列化(仓储/Handler 的推荐写法)。 func TestJSONOffsetAlwaysPlus8(t *testing.T) { original := time.Local t.Cleanup(func() { time.Local = original }) t.Setenv("TZ", "UTC") Apply() type payload struct { Now time.Time `json:"now"` FromDB time.Time `json:"fromDb"` Explicit time.Time `json:"explicit"` } // 模拟"DB 里存的是无时区 datetime(3),驱动按 loc=Asia/Shanghai 扫成 +08:00" dbTime := time.Date(2026, 3, 1, 12, 30, 45, 123_000_000, Location()) raw, err := json.Marshal(payload{ Now: time.Now(), FromDB: dbTime.In(Location()), Explicit: time.Date(2026, 12, 31, 23, 59, 59, 0, Location()), }) if err != nil { t.Fatalf("序列化失败: %v", err) } var decoded map[string]string if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("反序列化失败: %v", err) } for _, field := range []string{"now", "fromDb", "explicit"} { value, ok := decoded[field] if !ok { t.Fatalf("JSON 缺少字段 %s: %s", field, raw) } if !rfc3339Plus8.MatchString(value) { t.Fatalf("字段 %s = %q,不匹配契约 ^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\+08:00$(TZ=UTC 下必须仍是 +08:00)", field, value) } } // 边界日:跨年零点必须是本地日历日(UTC 会退到 12-31 15:59:59,账期错位) if got := decoded["explicit"]; got != "2026-12-31T23:59:59+08:00" { t.Fatalf("边界时间 = %q,期望 2026-12-31T23:59:59+08:00", got) } } // TestLocationNeverFallsBackToUTC 时区加载的纵深防御:Location 名恒为 Asia/Shanghai、偏移恒 +8h。 // // 即使容器内缺 tzdata(极端裁剪镜像),loadLocation 也只会回退到**固定 +08:00 区**, // 绝不会回退 UTC(那会把账期整体错 8 小时)。 func TestLocationNeverFallsBackToUTC(t *testing.T) { loc := Location() if loc.String() != LocationName { t.Fatalf("Location = %q,期望 %q(不得回退 UTC)", loc.String(), LocationName) } if _, offset := time.Date(2026, 6, 1, 0, 0, 0, 0, loc).Zone(); offset != fixedZoneOffset { t.Fatalf("偏移 = %d 秒,期望 %d(中国无夏令时,全年 +08:00)", offset, fixedZoneOffset) } } // TestNormalizeDSN DSN 时区收口:覆盖 loc=Local / 缺失 / 已正确三种输入。 func TestNormalizeDSN(t *testing.T) { cases := []struct { name string in string want string }{ { name: "loc=Local 必须被改写(随机器漂移的根因)", in: "dev:pass@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=True&loc=Local", want: "dev:pass@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai", }, { name: "loc=UTC 同样改写", in: "u:p@tcp(h:3306)/db?parseTime=False&loc=UTC", want: "u:p@tcp(h:3306)/db?parseTime=true&loc=Asia%2FShanghai", }, { name: "无查询串 → 自动补 ? 与两个参数", in: "u:p@tcp(h:3306)/db", want: "u:p@tcp(h:3306)/db?parseTime=true&loc=Asia%2FShanghai", }, { name: "已正确 → 幂等(其余参数与顺序不动)", in: "u:p@tcp(h:3306)/db?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai&timeout=5s", want: "u:p@tcp(h:3306)/db?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai&timeout=5s", }, { name: "空 DSN 原样返回(由调用方 fail-fast)", in: " ", want: "", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := NormalizeDSN(tc.in) if got != tc.want { t.Fatalf("NormalizeDSN(%q)\n got = %q\nwant = %q", tc.in, got, tc.want) } // 归一结果必须幂等(重复调用不再变化) if again := NormalizeDSN(got); again != got { t.Fatalf("NormalizeDSN 不幂等:%q → %q", got, again) } if got != "" && strings.Contains(got, "loc=Local") { t.Fatalf("归一后仍含 loc=Local:%q", got) } }) } }