// 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, "&") }