Files
yml2213 e56a5ea469 refactor(time): 不再全局改写 time.Local,业务时间统一走显式时区
main.go 原本在启动时执行 time.Local = timeutil.Location(),靠隐式改写进程全局
状态把时间钉在 +08:00。副作用是任何跑在这行之前的代码都处于另一个时区,且依赖
关系完全不可见。

- 移除该全局赋值,业务时间全部走 timeutil 的显式时区
- rock_snapshot.parseRockTime 是唯一真实依赖,且是会出事的那一处:上游返回不带
  偏移的北京时间墙面时间,用 time.Local 解析会在 TZ≠+08:00 时整体偏移 8 小时,
  把「本次新绑定」误判为「历史残留」而放回库存,直接造成重复发货。
  改为 timeutil.Location() 并补回归测试
- 有一点无法绕开:pgx 会把 timestamptz 解码到 time.Local,所以接口 JSON 的时间
  偏移仍由进程时区决定。新增 timeutil.LocalZoneNotice(),启动时把不一致显式告警,
  交由运维用 TZ=Asia/Shanghai 声明(Docker 部署已内置),而不是靠代码偷偷改全局
- 测试时间基线改用业务时区,避免随宿主机 TZ 漂移

验证:全量测试在默认时区、TZ=UTC、TZ=America/New_York 三种环境下均通过;
实测 TZ=UTC 启动打印时区提示,业务时区下静默。
2026-10-06 19:58:34 +08:00

59 lines
1.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package timeutil
import (
"fmt"
"time"
)
const (
APITimeLayout = "2006-01-02T15:04:05-07:00"
OrderNoLayout = "20060102150405"
// BusinessZoneOffsetSeconds 是业务时区(北京时间)相对 UTC 的偏移秒数。
BusinessZoneOffsetSeconds = 8 * 60 * 60
)
var businessLocation = time.FixedZone("CST", BusinessZoneOffsetSeconds)
func Location() *time.Location {
return businessLocation
}
// LocalZoneNotice 在进程本地时区与业务时区不一致时返回提示文本,一致时返回空串。
//
// 为什么需要这个检查:pgx 会把 Postgres 的 timestamptz 解码到 time.Local,
// 因此接口 JSON 里时间的偏移量由「进程时区」决定。对外契约要求统一为北京时间
// (见 docs/API对接关系.md),Docker 部署靠 TZ=Asia/Shanghai 满足它。
//
// 历史上这里用 `time.Local = timeutil.Location()` 隐式改写全局变量来达到目的,
// 副作用是任何读到那行之前的代码都可能在另一个时区下运行。现在改为在启动时
// 把不一致显式暴露出来,由运维通过 TZ 显式声明,而不是靠代码偷偷改全局状态。
func LocalZoneNotice() string {
name, offset := time.Now().Zone()
if offset == BusinessZoneOffsetSeconds {
return ""
}
return fmt.Sprintf(
"进程本地时区为 %s(UTC%+d),与业务时区 UTC+8 不一致。pgx 会把 timestamptz 解码到 time.Local,"+
"因此接口返回的时间偏移量会随之变化;请显式设置 TZ=Asia/Shanghai(Docker 部署已内置)。",
name, offset/3600,
)
}
func Now() time.Time {
return time.Now().In(businessLocation)
}
func FormatAPITime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.In(businessLocation).Truncate(time.Second).Format(APITimeLayout)
}
func StartOfDay(value time.Time) time.Time {
local := value.In(businessLocation)
year, month, day := local.Date()
return time.Date(year, month, day, 0, 0, 0, 0, businessLocation)
}