tz.go 5.4 KB

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