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 启动打印时区提示,业务时区下静默。
This commit is contained in:
@@ -36,7 +36,6 @@ func secondsToDurations(seconds []int) []time.Duration {
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
time.Local = timeutil.Location()
|
||||
|
||||
// 日志:控制台 + 文件
|
||||
applog.SetRetainDays(cfg.LogRetainDays)
|
||||
@@ -49,6 +48,13 @@ func main() {
|
||||
defer logFile.Close()
|
||||
}
|
||||
|
||||
// 这里刻意不再改写 time.Local 全局变量。业务时间统一走 timeutil 的显式时区;
|
||||
// 唯一无法绕开的是 pgx 会把 timestamptz 解码到 time.Local,因此把进程时区的
|
||||
// 不一致显式暴露出来,交由运维通过 TZ 声明。
|
||||
if notice := timeutil.LocalZoneNotice(); notice != "" {
|
||||
applog.Warnf("[CONFIG] 时区提示: %s", notice)
|
||||
}
|
||||
|
||||
gin.SetMode(cfg.Mode)
|
||||
|
||||
// 密钥配置体检:让「示例密钥被带到真实部署」这类问题在启动日志里立刻可见。
|
||||
|
||||
@@ -1,18 +1,45 @@
|
||||
package timeutil
|
||||
|
||||
import "time"
|
||||
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", 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)
|
||||
}
|
||||
|
||||
@@ -19,3 +19,25 @@ func TestStartOfDayUsesBusinessTimezone(t *testing.T) {
|
||||
t.Fatalf("unexpected day start: %s", got.Format(APITimeLayout))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalZoneNotice 锁住启动体检:进程时区偏移不等于 +08:00 时必须给出提示,
|
||||
// 否则 pgx 把 timestamptz 解码到 time.Local 会让接口返回的时间偏移悄悄变化。
|
||||
func TestLocalZoneNotice(t *testing.T) {
|
||||
original := time.Local
|
||||
t.Cleanup(func() { time.Local = original })
|
||||
|
||||
time.Local = time.FixedZone("CST", BusinessZoneOffsetSeconds)
|
||||
if got := LocalZoneNotice(); got != "" {
|
||||
t.Fatalf("业务时区不应告警,实际 %q", got)
|
||||
}
|
||||
|
||||
time.Local = time.UTC
|
||||
if got := LocalZoneNotice(); got == "" {
|
||||
t.Fatal("UTC 进程时区必须告警")
|
||||
}
|
||||
|
||||
time.Local = time.FixedZone("EST", -5*60*60)
|
||||
if got := LocalZoneNotice(); got == "" {
|
||||
t.Fatal("负偏移进程时区必须告警")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"affiliate_dash/internal/pkg/timeutil"
|
||||
"affiliate_dash/internal/testdb"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -655,7 +656,9 @@ func TestReleaseUnboundUsingToken(t *testing.T) {
|
||||
}
|
||||
|
||||
// 上游 status=bound,但发货记录早于本次领取、绑定时间仍为空:历史残留,可以再次使用。
|
||||
claimedAt := time.Date(2026, 10, 5, 20, 32, 17, 0, time.Local)
|
||||
// 上游时间串不带偏移,统一按业务时区解析;这里的时间基线也必须用同一时区,
|
||||
// 否则测试结果会随宿主机 TZ 漂移。
|
||||
claimedAt := time.Date(2026, 10, 5, 20, 32, 17, 0, timeutil.Location())
|
||||
stale := model.RockToken{
|
||||
UpstreamTokenID: "152415",
|
||||
Token: "jwt3",
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"affiliate_dash/internal/pkg/timeutil"
|
||||
)
|
||||
|
||||
// rockBuyerNewlyBound 判断上游展示的绑定是不是本次领取之后新扫上的。
|
||||
@@ -64,6 +65,9 @@ func rockClaimBaseline(token *model.RockToken) time.Time {
|
||||
return token.CreatedAt.UTC()
|
||||
}
|
||||
|
||||
// parseRockTime 解析上游返回的时间串。
|
||||
// 上游(0666.pro)返回的是不带偏移的北京时间,必须以业务时区解析,
|
||||
// 不能依赖 time.Local——否则进程时区一变,绑定时序判断就会整体偏移 8 小时。
|
||||
func parseRockTime(raw string) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
@@ -76,7 +80,7 @@ func parseRockTime(raw string) (time.Time, bool) {
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
|
||||
if t, err := time.ParseInLocation(layout, raw, timeutil.Location()); err == nil {
|
||||
return t.UTC(), true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestParseRockTimeUsesBusinessZone 锁定「上游不带偏移的时间串必须按北京时间解析」。
|
||||
//
|
||||
// 回归背景:上游返回的是不带偏移的北京时间墙面时间,用 time.Local 解析等于假设
|
||||
// 进程时区就是北京时间。一旦 TZ≠+08:00,解析出的瞬时值就会整体偏移
|
||||
// (UTC 下差 8 小时),而绑定时序判断依赖它来区分「历史残留」与「本次新绑定」——
|
||||
// 误判会把刚扫码绑定的 Token 放回库存,直接造成重复发货。
|
||||
func TestParseRockTimeUsesBusinessZone(t *testing.T) {
|
||||
original := time.Local
|
||||
time.Local = time.UTC
|
||||
t.Cleanup(func() { time.Local = original })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want time.Time
|
||||
}{
|
||||
{
|
||||
name: "空格分隔的北京时间按 +08:00 解析",
|
||||
raw: "2026-10-05 20:32:17",
|
||||
want: time.Date(2026, 10, 5, 12, 32, 17, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "T 分隔且无偏移",
|
||||
raw: "2026-09-27T14:38:51.23",
|
||||
want: time.Date(2026, 9, 27, 6, 38, 51, 230000000, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "自带偏移的时间串不受进程时区影响",
|
||||
raw: "2026-10-05T20:32:17Z",
|
||||
want: time.Date(2026, 10, 5, 20, 32, 17, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := parseRockTime(tc.raw)
|
||||
if !ok {
|
||||
t.Fatalf("解析 %q 失败", tc.raw)
|
||||
}
|
||||
if !got.Equal(tc.want) {
|
||||
t.Fatalf("解析 %q 期望 %s,实际 %s", tc.raw, tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRockTimeRejectsEmpty 空串与空白必须解析失败。
|
||||
func TestParseRockTimeRejectsEmpty(t *testing.T) {
|
||||
for _, raw := range []string{"", " "} {
|
||||
if _, ok := parseRockTime(raw); ok {
|
||||
t.Fatalf("%q 不应解析成功", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user