| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118 |
- // Package tz 进程时区与 DSN 时区收口(**后端唯一时区权威**)。
- //
- // 为什么要固定 `+08:00`(不是"跟随机器时区"):
- //
- // 财务账期按**本地日历日**结算(充值/消费/退费/分成的"当日"、"当月"口径来自业务方的中国工作日),
- // 若进程跑在 UTC 容器里,`2026-01-01 07:59:59 +08:00` 会被算成 UTC 的 2025-12-31,
- // 跨日/跨月统计整体错位一天(对账、账单、报表全部连锁出错)。
- // 前端契约(与 identity/org 现状一致)也已定死时间字段为 **RFC3339 带 `+08:00`**,
- // 一旦偏移随机器漂移,前端解析会整体错 8 小时。
- //
- // 三件套**必须同时成立**(缺一即不可靠,见下方各自的函数):
- //
- // ① 进程时区 time.Local = Asia/Shanghai → Apply()
- // ② DSN 时区 loc=Asia%2FShanghai → NormalizeDSN()
- // ③ 验收 在 TZ=UTC 下仍输出 +08:00 → tz_test.go 的负例断言
- //
- // 反面教材(本项目修复前的现状):DSN 写 `loc=Local` 且进程未设时区 ——
- // `datetime(3)` 是无时区列,"写入按机器时区、读出按机器时区",开发机(Asia/Shanghai)自测永远绿,
- // 一上 UTC 容器就整体错 8 小时,且两边时间都"看起来合理",极难排查。
- package tz
- import (
- "strings"
- "time"
- // 内嵌 IANA 时区库:容器/最小镜像(甚至 Windows 开发机)没有 /usr/share/zoneinfo 时,
- // 仍能 LoadLocation("Asia/Shanghai"),避免"时区加载失败 → 静默回退 UTC"的隐性错位。
- _ "time/tzdata"
- )
- // LocationName 进程与 DSN 统一使用的 IANA 时区名(中国标准时间,UTC+8,无夏令时)。
- const LocationName = "Asia/Shanghai"
- // dsnLocationParam DSN 中要写入的 `loc` 值(IANA 名做 URL 编码:`/` → `%2F`)。
- //
- // 为什么必须编码:go-sql-driver/mysql 把 DSN 当 URL 解析,未编码的 `Asia/Shanghai` 会被
- // 当作路径分隔符切断,最终 loc 变成非法值或空 → 回落到 UTC。
- const dsnLocationParam = "Asia%2FShanghai"
- // fixedZoneOffset 兜底时区偏移(秒)。中国无夏令时,固定 +08:00 与 Asia/Shanghai 等价。
- const fixedZoneOffset = 8 * 60 * 60
- // location 缓存的时区对象(LoadLocation 只做一次;并发安全:由 sync.Once 语义保证只写一次)。
- var location = loadLocation()
- // loadLocation 加载 Asia/Shanghai(失败时回退固定 +08:00 区,**绝不**回退 UTC)。
- //
- // 出参:*time.Location——名字恒为 "Asia/Shanghai"(便于断言与日志排查)。
- //
- // 说明:内嵌 tzdata 后几乎不会失败;兜底逻辑只为"二进制被裁剪 tzdata"的极端场景保命,
- // 保证偏移仍是 +08:00 而不是漂成 UTC。
- func loadLocation() *time.Location {
- if loc, err := time.LoadLocation(LocationName); err == nil {
- return loc
- }
- return time.FixedZone(LocationName, fixedZoneOffset)
- }
- // Location 返回统一的 Asia/Shanghai 时区对象(只读,可安全共享)。
- //
- // 典型用法:`row.CreateTime.In(tz.Location())`、`time.Now().In(tz.Location())`。
- func Location() *time.Location { return location }
- // Apply 把进程时区固定为 Asia/Shanghai(**必须在任何 time.Now() / DB 连接之前调用**)。
- //
- // 出参:*time.Location——已生效的时区(供调用方日志打印或断言)。
- //
- // 副作用:改写全局 `time.Local`。这是 Go 标准库唯一的进程级时区开关:
- // `time.Now()` 返回 Local 区时间,`time.Time` 默认 JSON 序列化按自身 Location 输出偏移,
- // 故改它即可让"未显式指定时区"的所有时间点统一带 `+08:00`。
- //
- // 幂等:重复调用无副作用(同值覆盖)。
- func Apply() *time.Location {
- time.Local = location
- return location
- }
- // NormalizeDSN 强制把 MySQL DSN 的时区与解析行为对齐到本包口径。
- //
- // 入参:dsn string——原始 DSN(形如 `user:pass@tcp(host:3306)/db?charset=utf8mb4&parseTime=True&loc=Local`)。
- //
- // 出参:string——归一后的 DSN:
- // - `loc` 参数**覆盖**为 `Asia%2FShanghai`(无论原来是 `Local`/`UTC`/缺失):
- // `loc=Local` 恰恰是"随机器时区漂移"的根因,不能只补不改;
- // - `parseTime` 强制为 `true`:否则驱动把 DATETIME 扫成 `[]byte`/`string`,`time.Time` 字段扫描报错;
- // - 无查询串时自动补 `?`,其余参数原样保留(顺序不重排,便于 diff/日志比对)。
- //
- // 空 DSN 原样返回(由调用方 fail-fast 校验,不在本函数报错)。
- //
- // 红线:本函数是**唯一**允许写 DSN 时区的地方;业务代码不得各自拼 `loc=`(否则又出现多口径)。
- func NormalizeDSN(dsn string) string {
- dsn = strings.TrimSpace(dsn)
- if dsn == "" {
- return dsn
- }
- // 拆分 "前导部分?查询串"(DSN 内可能含 `?` 以外的特殊字符,故只按第一个 `?` 切)
- head, query, hasQuery := strings.Cut(dsn, "?")
- params := make([]string, 0, 8)
- if hasQuery && query != "" {
- params = strings.Split(query, "&")
- }
- // 逐参数覆盖(保留未知参数,保持原有顺序)
- setParam := func(key, value string) {
- for i, kv := range params {
- k, _, _ := strings.Cut(kv, "=")
- if strings.EqualFold(strings.TrimSpace(k), key) {
- params[i] = key + "=" + value
- return
- }
- }
- params = append(params, key+"="+value)
- }
- setParam("parseTime", "true")
- setParam("loc", dsnLocationParam)
- return head + "?" + strings.Join(params, "&")
- }
|