From 358036a84762e2cbd2ad22d68943eccc2cfedd89 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 28 May 2026 11:44:54 +0800 Subject: [PATCH] fix: align dashboard gift time windows --- internal/cdc/handler.go | 3 ++- internal/config/config.go | 2 ++ internal/dashboard/mapper.go | 8 +++++++- internal/dashboard/mapper_helpers.go | 10 ++++++++- internal/dashboard/mapper_wallet_test.go | 26 ++++++++++++++++++++++++ internal/dashboard/period.go | 14 ++++++------- internal/dashboard/period_test.go | 24 +++++++++++----------- internal/storage/gift_sql.go | 12 ++++++----- 8 files changed, 71 insertions(+), 28 deletions(-) diff --git a/internal/cdc/handler.go b/internal/cdc/handler.go index b2ef2d9..b781a58 100644 --- a/internal/cdc/handler.go +++ b/internal/cdc/handler.go @@ -37,7 +37,8 @@ func newHandler(cfg config.Config, store dashboard.Store, projector *dashboard.P store: store, projector: projector, mapper: dashboard.NewMapper(store, cfg.Dashboard.SysOrigins, - cfg.Dashboard.UnknownCountryCode, cfg.User.CreateTimeDisplayOffset), + cfg.Dashboard.UnknownCountryCode, cfg.User.CreateTimeDisplayOffset, + cfg.Dashboard.SourceTimeOffset), logger: logger, batch: make([]dashboard.Event, 0, cfg.CDC.BatchSize), lastFlush: time.Now(), diff --git a/internal/config/config.go b/internal/config/config.go index efada5f..fad0077 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,7 @@ type DashboardConfig struct { SysOrigins []string StatTimezones []string StorageTimezone string + SourceTimeOffset time.Duration RedisKeyPrefix string RealtimeRedisTTL time.Duration UnknownCountryCode string @@ -153,6 +154,7 @@ func Load() (Config, error) { SysOrigins: csv(env("DASHBOARD_SYS_ORIGINS", "LIKEI")), StatTimezones: csv(env("DASHBOARD_STAT_TIMEZONES", "Asia/Riyadh")), StorageTimezone: env("DASHBOARD_STORAGE_TIMEZONE", "Asia/Riyadh"), + SourceTimeOffset: envDuration("DASHBOARD_SOURCE_TIME_OFFSET", 8*time.Hour), RedisKeyPrefix: env("DASHBOARD_REDIS_KEY_PREFIX", "country_dashboard"), RealtimeRedisTTL: envDuration("DASHBOARD_REALTIME_REDIS_TTL", 48*time.Hour), UnknownCountryCode: env("DASHBOARD_UNKNOWN_COUNTRY_CODE", "UNKNOWN"), diff --git a/internal/dashboard/mapper.go b/internal/dashboard/mapper.go index 9aa3163..c4fc72d 100644 --- a/internal/dashboard/mapper.go +++ b/internal/dashboard/mapper.go @@ -13,19 +13,25 @@ type Mapper struct { sysOrigins map[string]struct{} unknownCountry string userCreateDisplayOffset time.Duration + sourceTimeOffset time.Duration } func NewMapper(store Store, sysOrigins []string, unknownCountry string, - userCreateDisplayOffset time.Duration) *Mapper { + userCreateDisplayOffset time.Duration, sourceTimeOffset ...time.Duration) *Mapper { origins := make(map[string]struct{}, len(sysOrigins)) for _, origin := range sysOrigins { origins[strings.TrimSpace(origin)] = struct{}{} } + offset := time.Duration(0) + if len(sourceTimeOffset) > 0 { + offset = sourceTimeOffset[0] + } return &Mapper{ store: store, sysOrigins: origins, unknownCountry: unknownCountry, userCreateDisplayOffset: userCreateDisplayOffset, + sourceTimeOffset: offset, } } diff --git a/internal/dashboard/mapper_helpers.go b/internal/dashboard/mapper_helpers.go index bedfaa9..ee0c647 100644 --- a/internal/dashboard/mapper_helpers.go +++ b/internal/dashboard/mapper_helpers.go @@ -29,11 +29,19 @@ func (m *Mapper) amountContribution( if user.SysOrigin != sysOrigin { return nil, nil } - c := m.base(schema, table, action, row, user, row.Time("create_time")) + c := m.base(schema, table, action, row, user, m.sourceTime(row, "create_time")) apply(&c, amount) return []Contribution{c}, nil } +func (m *Mapper) sourceTime(row Row, field string) time.Time { + value := row.Time(field) + if value.IsZero() || m.sourceTimeOffset == 0 { + return value + } + return value.Add(m.sourceTimeOffset) +} + func (m *Mapper) base(schema string, table string, action string, row Row, user UserCountry, eventTime time.Time) Contribution { return Contribution{ SourceSchema: schema, diff --git a/internal/dashboard/mapper_wallet_test.go b/internal/dashboard/mapper_wallet_test.go index 7f2e60c..2e37375 100644 --- a/internal/dashboard/mapper_wallet_test.go +++ b/internal/dashboard/mapper_wallet_test.go @@ -124,6 +124,32 @@ func TestUserCreateTimeDisplayOffsetIsApplied(t *testing.T) { } } +func TestSourceTimeOffsetIsAppliedToAmountEvents(t *testing.T) { + mapper := NewMapper(walletMapperTestStore{users: map[int64]UserCountry{ + 1001: {UserID: 1001, SysOrigin: "LIKEI", Country: Country{Code: "BD", Name: "Bangladesh"}}, + }}, []string{"LIKEI"}, "UNKNOWN", 0, 8*time.Hour) + decodedCreateTime := time.Date(2026, 5, 27, 19, 36, 0, 0, time.UTC) + + contributions, err := mapper.Map(context.Background(), "likei", "game_lucky_gift_count", "insert", Row{ + "id": int64(31), + "user_id": int64(1001), + "sys_origin": "LIKEI", + "pay_amount": decimal.NewFromInt(50), + "award_amount": decimal.NewFromInt(100), + "create_time": decodedCreateTime, + }) + if err != nil { + t.Fatal(err) + } + if len(contributions) != 1 { + t.Fatalf("expected one contribution, got %d", len(contributions)) + } + expected := decodedCreateTime.Add(8 * time.Hour) + if !contributions[0].EventTime.Equal(expected) { + t.Fatalf("expected event time %s, got %s", expected, contributions[0].EventTime) + } +} + func TestGoogleRechargeContributesToUserRecharge(t *testing.T) { mapper := NewMapper(walletMapperTestStore{users: map[int64]UserCountry{ 1001: { diff --git a/internal/dashboard/period.go b/internal/dashboard/period.go index 74c3f20..3338167 100644 --- a/internal/dashboard/period.go +++ b/internal/dashboard/period.go @@ -6,7 +6,7 @@ import ( ) func PeriodWindows(eventTime time.Time, statZone *time.Location) []PeriodWindow { - localDate := startOfDay(wallClockInLocation(eventTime, statZone)) + localDate := startOfDay(timeInLocation(eventTime, statZone)) dayEnd := localDate.AddDate(0, 0, 1) weekday := int(localDate.Weekday()) @@ -59,8 +59,8 @@ func SamePeriod(left time.Time, right time.Time, window PeriodWindow, statZone * if window.Type == PeriodAll { return true } - leftLocal := wallClockInLocation(left, statZone) - rightLocal := wallClockInLocation(right, statZone) + leftLocal := timeInLocation(left, statZone) + rightLocal := timeInLocation(right, statZone) switch window.Type { case PeriodDay: return startOfDay(leftLocal).Equal(startOfDay(rightLocal)) @@ -76,7 +76,7 @@ func SamePeriod(left time.Time, right time.Time, window PeriodWindow, statZone * } func DailyDate(eventTime time.Time, storageZone *time.Location) time.Time { - return startOfDay(wallClockInLocation(eventTime, storageZone)) + return startOfDay(timeInLocation(eventTime, storageZone)) } func DateNumber(day time.Time) int { @@ -94,14 +94,12 @@ func startOfDay(value time.Time) time.Time { return time.Date(year, month, date, 0, 0, 0, 0, value.Location()) } -func wallClockInLocation(value time.Time, location *time.Location) time.Time { +func timeInLocation(value time.Time, location *time.Location) time.Time { if value.IsZero() { return value } if location == nil { location = time.UTC } - year, month, day := value.Date() - hour, minute, second := value.Clock() - return time.Date(year, month, day, hour, minute, second, value.Nanosecond(), location) + return value.In(location) } diff --git a/internal/dashboard/period_test.go b/internal/dashboard/period_test.go index a8cc88c..9149a94 100644 --- a/internal/dashboard/period_test.go +++ b/internal/dashboard/period_test.go @@ -5,38 +5,38 @@ import ( "time" ) -func TestDailyDateKeepsDatabaseDatetimeCalendarDay(t *testing.T) { +func TestDailyDateUsesStatisticTimezone(t *testing.T) { riyadh := mustLoadLocation(t, "Asia/Riyadh") - eventTime := time.Date(2026, 5, 26, 1, 30, 0, 0, time.UTC) + eventTime := time.Date(2026, 5, 26, 21, 30, 0, 0, time.UTC) got := DailyDate(eventTime, riyadh) - if got.Format("2006-01-02") != "2026-05-26" { - t.Fatalf("DailyDate shifted database DATETIME calendar day: got %s", got.Format("2006-01-02")) + if got.Format("2006-01-02") != "2026-05-27" { + t.Fatalf("DailyDate = %s, want Riyadh day 2026-05-27", got.Format("2006-01-02")) } if got.Location() != riyadh { t.Fatalf("DailyDate location = %s, want %s", got.Location(), riyadh) } } -func TestPeriodWindowsKeepsDatabaseDatetimeCalendarDay(t *testing.T) { +func TestPeriodWindowsUseStatisticTimezone(t *testing.T) { riyadh := mustLoadLocation(t, "Asia/Riyadh") - eventTime := time.Date(2026, 5, 26, 1, 30, 0, 0, time.UTC) + eventTime := time.Date(2026, 5, 26, 21, 30, 0, 0, time.UTC) windows := PeriodWindows(eventTime, riyadh) - if windows[0].Type != PeriodDay || windows[0].Key != "2026-05-26" { - t.Fatalf("day window = %+v, want key 2026-05-26", windows[0]) + if windows[0].Type != PeriodDay || windows[0].Key != "2026-05-27" { + t.Fatalf("day window = %+v, want key 2026-05-27", windows[0]) } } -func TestSamePeriodKeepsDatabaseDatetimeCalendarDay(t *testing.T) { +func TestSamePeriodUsesStatisticTimezone(t *testing.T) { riyadh := mustLoadLocation(t, "Asia/Riyadh") - left := time.Date(2026, 5, 26, 0, 10, 0, 0, time.UTC) - right := time.Date(2026, 5, 26, 23, 50, 0, 0, time.UTC) + left := time.Date(2026, 5, 26, 21, 10, 0, 0, time.UTC) + right := time.Date(2026, 5, 27, 20, 50, 0, 0, time.UTC) if !SamePeriod(left, right, PeriodWindow{Type: PeriodDay}, riyadh) { - t.Fatal("SamePeriod shifted database DATETIME values out of the same calendar day") + t.Fatal("SamePeriod should match events in the same Riyadh day") } } diff --git a/internal/storage/gift_sql.go b/internal/storage/gift_sql.go index e2cd5c6..1eb17fb 100644 --- a/internal/storage/gift_sql.go +++ b/internal/storage/gift_sql.go @@ -12,8 +12,10 @@ import ( func (r *MySQLRepository) listSQLLuckyGiftRows(ctx context.Context, start, end time.Time, statTimezone string) ([]sqlLuckyGiftRow, error) { + startUTC := start.In(time.UTC).Format("2006-01-02 15:04:05") + endUTC := end.In(time.UTC).Format("2006-01-02 15:04:05") query := ` -SELECT CAST(t.id AS CHAR), t.user_id, DATE_FORMAT(CONVERT_TZ(t.create_time, '+03:00', ?), '%Y-%m-%d'), +SELECT CAST(t.id AS CHAR), t.user_id, DATE_FORMAT(CONVERT_TZ(t.create_time, '+00:00', ?), '%Y-%m-%d'), t.sys_origin, IFNULL(NULLIF(u.country_code, ''), ?), IFNULL(NULLIF(u.country_name, ''), IFNULL(NULLIF(u.country_code, ''), ?)), t.pay_amount, t.award_amount @@ -21,11 +23,11 @@ FROM ` + quoteIdent(r.cfg.MySQL.Schema) + `.game_lucky_gift_count t JOIN ` + quoteIdent(r.cfg.MySQL.Schema) + `.user_base_info u ON u.id = t.user_id WHERE t.create_time >= ? AND t.create_time < ? - AND t.sys_origin IN ` + inStringPlaceholders(len(r.cfg.Dashboard.SysOrigins)) + ` - AND u.origin_sys = t.sys_origin - AND (u.is_del = 0 OR u.is_del IS NULL)` + AND t.sys_origin IN ` + inStringPlaceholders(len(r.cfg.Dashboard.SysOrigins)) + ` + AND u.origin_sys = t.sys_origin + AND (u.is_del = 0 OR u.is_del IS NULL)` args := []interface{}{timezoneOffset(statTimezone), r.cfg.Dashboard.UnknownCountryCode, - r.cfg.Dashboard.UnknownCountryCode, start, end} + r.cfg.Dashboard.UnknownCountryCode, startUTC, endUTC} args = append(args, stringArgs(r.cfg.Dashboard.SysOrigins)...) rows, err := r.db.QueryContext(ctx, query, args...) if err != nil {