From e56a5ea46979caa901e63621c9a1d6157ecf0dd4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 6 Oct 2026 19:58:34 +0800 Subject: [PATCH] =?UTF-8?q?refactor(time):=20=E4=B8=8D=E5=86=8D=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E6=94=B9=E5=86=99=20time.Local=EF=BC=8C=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E6=97=B6=E9=97=B4=E7=BB=9F=E4=B8=80=E8=B5=B0=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E6=97=B6=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 启动打印时区提示,业务时区下静默。 --- backend/cmd/server/main.go | 8 ++- backend/internal/pkg/timeutil/timeutil.go | 31 +++++++++- .../internal/pkg/timeutil/timeutil_test.go | 22 +++++++ .../internal/service/provider_rock_test.go | 5 +- backend/internal/service/rock_snapshot.go | 6 +- .../internal/service/rock_snapshot_tz_test.go | 61 +++++++++++++++++++ 6 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 backend/internal/service/rock_snapshot_tz_test.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index aa78a09..0003858 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) // 密钥配置体检:让「示例密钥被带到真实部署」这类问题在启动日志里立刻可见。 diff --git a/backend/internal/pkg/timeutil/timeutil.go b/backend/internal/pkg/timeutil/timeutil.go index ffa68fa..c269e3f 100644 --- a/backend/internal/pkg/timeutil/timeutil.go +++ b/backend/internal/pkg/timeutil/timeutil.go @@ -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) } diff --git a/backend/internal/pkg/timeutil/timeutil_test.go b/backend/internal/pkg/timeutil/timeutil_test.go index 7a69b9b..6846806 100644 --- a/backend/internal/pkg/timeutil/timeutil_test.go +++ b/backend/internal/pkg/timeutil/timeutil_test.go @@ -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("负偏移进程时区必须告警") + } +} diff --git a/backend/internal/service/provider_rock_test.go b/backend/internal/service/provider_rock_test.go index de2b6c6..13b9b1e 100644 --- a/backend/internal/service/provider_rock_test.go +++ b/backend/internal/service/provider_rock_test.go @@ -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", diff --git a/backend/internal/service/rock_snapshot.go b/backend/internal/service/rock_snapshot.go index 591a576..6e1ce00 100644 --- a/backend/internal/service/rock_snapshot.go +++ b/backend/internal/service/rock_snapshot.go @@ -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 } } diff --git a/backend/internal/service/rock_snapshot_tz_test.go b/backend/internal/service/rock_snapshot_tz_test.go new file mode 100644 index 0000000..fb653f8 --- /dev/null +++ b/backend/internal/service/rock_snapshot_tz_test.go @@ -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) + } + } +}