Skip to content

Commit 2aee19b

Browse files
authored
fix(notification): preserve pause time across MySQL timezone reads (#756)
dbr 的 MySQL 方言按 UTC 写入 DATETIME 字面量,而读回由 go-sql-driver 依 DSN 的 loc 解析。loc=Local 部署下二者不对称,paused_until 读回被平移(UTC+8 下为 -8h)。 由此产生的故障形态是静默静音:过期判断在 SQL 侧完成,离线推送确实被抑制,但 API 与跨设备 CMD 走 Go 侧读回,回报 paused=false,所有设备显示未开启。 - 在 DB 读边界用墙钟分量重建 UTC,get/upsert/clear 三条读取路径统一归一化 - 变更响应改用写入时捕获的 UTC 快照,与入口处的校验时刻保持一致 - 新增 loc=Asia/Shanghai 的往返回归测试,不依赖 runner 的 TZ
1 parent 0095672 commit 2aee19b

4 files changed

Lines changed: 81 additions & 5 deletions

File tree

modules/notification/api.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func (s *Service) putPause(c *wkhttp.Context) {
6363
s.writeStoreError(c, err)
6464
return
6565
}
66-
response := s.response(record, time.Now().UTC())
66+
response := s.response(record, now)
6767
if err := s.sendChangedCMD(c.GetLoginUID(), response); err != nil {
6868
s.Warn("发送通知暂停状态 CMD 失败", zap.String("uid", c.GetLoginUID()), zap.Error(err))
6969
}
@@ -77,7 +77,7 @@ func (s *Service) deletePause(c *wkhttp.Context) {
7777
s.writeStoreError(c, err)
7878
return
7979
}
80-
response := s.response(record, time.Now().UTC())
80+
response := s.response(record, now)
8181
if err := s.sendChangedCMD(c.GetLoginUID(), response); err != nil {
8282
s.Warn("发送通知暂停状态 CMD 失败", zap.String("uid", c.GetLoginUID()), zap.Error(err))
8383
}

modules/notification/api_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ func TestResponseUsesUTCAbsolutePauseTime(t *testing.T) {
4141
}
4242
}
4343

44+
func TestNormalizePauseRecordPreservesDatabaseUTCWallClock(t *testing.T) {
45+
stored := time.Date(2026, 8, 15, 8, 20, 30, 123000000, time.FixedZone("CST", 8*60*60))
46+
record := normalizePauseRecord(&pauseRecord{PausedUntil: &stored})
47+
48+
if record.PausedUntil == nil {
49+
t.Fatal("paused_until should remain present")
50+
}
51+
want := time.Date(2026, 8, 15, 8, 20, 30, 123000000, time.UTC)
52+
if !record.PausedUntil.Equal(want) || record.PausedUntil.Location() != time.UTC {
53+
t.Fatalf("paused_until should preserve UTC wall-clock value, got %s", record.PausedUntil)
54+
}
55+
}
56+
4457
func TestValidPauseUntilCapsThePauseWindow(t *testing.T) {
4558
now := time.Date(2026, 8, 12, 11, 30, 0, 0, time.UTC)
4659
if !validPauseUntil(now, now.Add(time.Minute)) {

modules/notification/db.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ type dbStore struct {
1111
session *dbr.Session
1212
}
1313

14+
// dbr interpolates MySQL time arguments as UTC text. Rebuild values read from
15+
// DATETIME using their wall-clock components so a connection configured with
16+
// loc=Local does not shift the persisted UTC value on read-back.
17+
func normalizePauseRecord(record *pauseRecord) *pauseRecord {
18+
if record == nil || record.PausedUntil == nil {
19+
return record
20+
}
21+
value := *record.PausedUntil
22+
utc := time.Date(value.Year(), value.Month(), value.Day(), value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), time.UTC)
23+
record.PausedUntil = &utc
24+
return record
25+
}
26+
1427
func newDBStore(ctx *config.Context) *dbStore {
1528
return &dbStore{session: ctx.DB()}
1629
}
@@ -21,7 +34,7 @@ func (s *dbStore) get(uid string) (*pauseRecord, error) {
2134
From("user_notification_pause").
2235
Where("uid=?", uid).
2336
Load(&record)
24-
return record, err
37+
return normalizePauseRecord(record), err
2538
}
2639

2740
func (s *dbStore) upsert(uid string, pausedUntil time.Time, now time.Time) (*pauseRecord, error) {
@@ -51,7 +64,7 @@ func (s *dbStore) upsert(uid string, pausedUntil time.Time, now time.Time) (*pau
5164
if err := tx.Commit(); err != nil {
5265
return nil, err
5366
}
54-
return record, nil
67+
return normalizePauseRecord(record), nil
5568
}
5669

5770
func (s *dbStore) clear(uid string, now time.Time) (*pauseRecord, error) {
@@ -82,7 +95,7 @@ func (s *dbStore) clear(uid string, now time.Time) (*pauseRecord, error) {
8295
if err := tx.Commit(); err != nil {
8396
return nil, err
8497
}
85-
return record, nil
98+
return normalizePauseRecord(record), nil
8699
}
87100

88101
func (s *dbStore) getActiveByUIDs(uids []string, now time.Time) (map[string]struct{}, error) {

modules/notification/db_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package notification
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
"time"
7+
8+
"github.com/Mininglamp-OSS/octo-lib/config"
9+
"github.com/Mininglamp-OSS/octo-lib/testutil"
10+
)
11+
12+
func TestPauseTimeSurvivesNonUTCLocRoundTrip(t *testing.T) {
13+
cfg := config.New()
14+
cfg.Test = true
15+
cfg.DB.MySQLAddr = "root:demo@tcp(127.0.0.1:3306)/test?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai"
16+
cfg.DB.Migration = false
17+
ctx := testutil.NewTestContext(cfg)
18+
if err := ctx.DB().DB.Ping(); err != nil {
19+
t.Skipf("MySQL unavailable: %v", err)
20+
}
21+
_, err := ctx.DB().DB.Exec(`CREATE TABLE IF NOT EXISTS user_notification_pause (
22+
uid VARCHAR(40) NOT NULL,
23+
paused_until DATETIME(3) NULL,
24+
revision BIGINT UNSIGNED NOT NULL DEFAULT 0,
25+
updated_at DATETIME(3) NOT NULL,
26+
PRIMARY KEY (uid)
27+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci`)
28+
if err != nil {
29+
t.Fatal(err)
30+
}
31+
32+
uid := fmt.Sprintf("notification-tz-%d", time.Now().UnixNano())
33+
t.Cleanup(func() { _, _ = ctx.DB().DB.Exec("DELETE FROM user_notification_pause WHERE uid=?", uid) })
34+
store := newDBStore(ctx)
35+
now := time.Now().UTC().Truncate(time.Millisecond)
36+
want := now.Add(2 * time.Hour)
37+
if _, err := store.upsert(uid, want, now); err != nil {
38+
t.Fatal(err)
39+
}
40+
record, err := store.get(uid)
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
if record == nil || record.PausedUntil == nil || !record.PausedUntil.Equal(want) {
45+
t.Fatalf("paused_until round trip = %v, want %v", record, want)
46+
}
47+
if response := (&Service{}).response(record, now); !response.Paused || response.PausedUntil == nil || !response.PausedUntil.Equal(want) {
48+
t.Fatalf("response = %+v, want active pause until %s", response, want)
49+
}
50+
}

0 commit comments

Comments
 (0)