fix: align dashboard gift time windows
This commit is contained in:
parent
fcc11122d4
commit
358036a847
@ -37,7 +37,8 @@ func newHandler(cfg config.Config, store dashboard.Store, projector *dashboard.P
|
|||||||
store: store,
|
store: store,
|
||||||
projector: projector,
|
projector: projector,
|
||||||
mapper: dashboard.NewMapper(store, cfg.Dashboard.SysOrigins,
|
mapper: dashboard.NewMapper(store, cfg.Dashboard.SysOrigins,
|
||||||
cfg.Dashboard.UnknownCountryCode, cfg.User.CreateTimeDisplayOffset),
|
cfg.Dashboard.UnknownCountryCode, cfg.User.CreateTimeDisplayOffset,
|
||||||
|
cfg.Dashboard.SourceTimeOffset),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
batch: make([]dashboard.Event, 0, cfg.CDC.BatchSize),
|
batch: make([]dashboard.Event, 0, cfg.CDC.BatchSize),
|
||||||
lastFlush: time.Now(),
|
lastFlush: time.Now(),
|
||||||
|
|||||||
@ -65,6 +65,7 @@ type DashboardConfig struct {
|
|||||||
SysOrigins []string
|
SysOrigins []string
|
||||||
StatTimezones []string
|
StatTimezones []string
|
||||||
StorageTimezone string
|
StorageTimezone string
|
||||||
|
SourceTimeOffset time.Duration
|
||||||
RedisKeyPrefix string
|
RedisKeyPrefix string
|
||||||
RealtimeRedisTTL time.Duration
|
RealtimeRedisTTL time.Duration
|
||||||
UnknownCountryCode string
|
UnknownCountryCode string
|
||||||
@ -153,6 +154,7 @@ func Load() (Config, error) {
|
|||||||
SysOrigins: csv(env("DASHBOARD_SYS_ORIGINS", "LIKEI")),
|
SysOrigins: csv(env("DASHBOARD_SYS_ORIGINS", "LIKEI")),
|
||||||
StatTimezones: csv(env("DASHBOARD_STAT_TIMEZONES", "Asia/Riyadh")),
|
StatTimezones: csv(env("DASHBOARD_STAT_TIMEZONES", "Asia/Riyadh")),
|
||||||
StorageTimezone: env("DASHBOARD_STORAGE_TIMEZONE", "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"),
|
RedisKeyPrefix: env("DASHBOARD_REDIS_KEY_PREFIX", "country_dashboard"),
|
||||||
RealtimeRedisTTL: envDuration("DASHBOARD_REALTIME_REDIS_TTL", 48*time.Hour),
|
RealtimeRedisTTL: envDuration("DASHBOARD_REALTIME_REDIS_TTL", 48*time.Hour),
|
||||||
UnknownCountryCode: env("DASHBOARD_UNKNOWN_COUNTRY_CODE", "UNKNOWN"),
|
UnknownCountryCode: env("DASHBOARD_UNKNOWN_COUNTRY_CODE", "UNKNOWN"),
|
||||||
|
|||||||
@ -13,19 +13,25 @@ type Mapper struct {
|
|||||||
sysOrigins map[string]struct{}
|
sysOrigins map[string]struct{}
|
||||||
unknownCountry string
|
unknownCountry string
|
||||||
userCreateDisplayOffset time.Duration
|
userCreateDisplayOffset time.Duration
|
||||||
|
sourceTimeOffset time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMapper(store Store, sysOrigins []string, unknownCountry string,
|
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))
|
origins := make(map[string]struct{}, len(sysOrigins))
|
||||||
for _, origin := range sysOrigins {
|
for _, origin := range sysOrigins {
|
||||||
origins[strings.TrimSpace(origin)] = struct{}{}
|
origins[strings.TrimSpace(origin)] = struct{}{}
|
||||||
}
|
}
|
||||||
|
offset := time.Duration(0)
|
||||||
|
if len(sourceTimeOffset) > 0 {
|
||||||
|
offset = sourceTimeOffset[0]
|
||||||
|
}
|
||||||
return &Mapper{
|
return &Mapper{
|
||||||
store: store,
|
store: store,
|
||||||
sysOrigins: origins,
|
sysOrigins: origins,
|
||||||
unknownCountry: unknownCountry,
|
unknownCountry: unknownCountry,
|
||||||
userCreateDisplayOffset: userCreateDisplayOffset,
|
userCreateDisplayOffset: userCreateDisplayOffset,
|
||||||
|
sourceTimeOffset: offset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,11 +29,19 @@ func (m *Mapper) amountContribution(
|
|||||||
if user.SysOrigin != sysOrigin {
|
if user.SysOrigin != sysOrigin {
|
||||||
return nil, nil
|
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)
|
apply(&c, amount)
|
||||||
return []Contribution{c}, nil
|
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 {
|
func (m *Mapper) base(schema string, table string, action string, row Row, user UserCountry, eventTime time.Time) Contribution {
|
||||||
return Contribution{
|
return Contribution{
|
||||||
SourceSchema: schema,
|
SourceSchema: schema,
|
||||||
|
|||||||
@ -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) {
|
func TestGoogleRechargeContributesToUserRecharge(t *testing.T) {
|
||||||
mapper := NewMapper(walletMapperTestStore{users: map[int64]UserCountry{
|
mapper := NewMapper(walletMapperTestStore{users: map[int64]UserCountry{
|
||||||
1001: {
|
1001: {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func PeriodWindows(eventTime time.Time, statZone *time.Location) []PeriodWindow {
|
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)
|
dayEnd := localDate.AddDate(0, 0, 1)
|
||||||
|
|
||||||
weekday := int(localDate.Weekday())
|
weekday := int(localDate.Weekday())
|
||||||
@ -59,8 +59,8 @@ func SamePeriod(left time.Time, right time.Time, window PeriodWindow, statZone *
|
|||||||
if window.Type == PeriodAll {
|
if window.Type == PeriodAll {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
leftLocal := wallClockInLocation(left, statZone)
|
leftLocal := timeInLocation(left, statZone)
|
||||||
rightLocal := wallClockInLocation(right, statZone)
|
rightLocal := timeInLocation(right, statZone)
|
||||||
switch window.Type {
|
switch window.Type {
|
||||||
case PeriodDay:
|
case PeriodDay:
|
||||||
return startOfDay(leftLocal).Equal(startOfDay(rightLocal))
|
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 {
|
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 {
|
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())
|
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() {
|
if value.IsZero() {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
if location == nil {
|
if location == nil {
|
||||||
location = time.UTC
|
location = time.UTC
|
||||||
}
|
}
|
||||||
year, month, day := value.Date()
|
return value.In(location)
|
||||||
hour, minute, second := value.Clock()
|
|
||||||
return time.Date(year, month, day, hour, minute, second, value.Nanosecond(), location)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,38 +5,38 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDailyDateKeepsDatabaseDatetimeCalendarDay(t *testing.T) {
|
func TestDailyDateUsesStatisticTimezone(t *testing.T) {
|
||||||
riyadh := mustLoadLocation(t, "Asia/Riyadh")
|
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)
|
got := DailyDate(eventTime, riyadh)
|
||||||
|
|
||||||
if got.Format("2006-01-02") != "2026-05-26" {
|
if got.Format("2006-01-02") != "2026-05-27" {
|
||||||
t.Fatalf("DailyDate shifted database DATETIME calendar day: got %s", got.Format("2006-01-02"))
|
t.Fatalf("DailyDate = %s, want Riyadh day 2026-05-27", got.Format("2006-01-02"))
|
||||||
}
|
}
|
||||||
if got.Location() != riyadh {
|
if got.Location() != riyadh {
|
||||||
t.Fatalf("DailyDate location = %s, want %s", 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")
|
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)
|
windows := PeriodWindows(eventTime, riyadh)
|
||||||
|
|
||||||
if windows[0].Type != PeriodDay || windows[0].Key != "2026-05-26" {
|
if windows[0].Type != PeriodDay || windows[0].Key != "2026-05-27" {
|
||||||
t.Fatalf("day window = %+v, want key 2026-05-26", windows[0])
|
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")
|
riyadh := mustLoadLocation(t, "Asia/Riyadh")
|
||||||
left := time.Date(2026, 5, 26, 0, 10, 0, 0, time.UTC)
|
left := time.Date(2026, 5, 26, 21, 10, 0, 0, time.UTC)
|
||||||
right := time.Date(2026, 5, 26, 23, 50, 0, 0, time.UTC)
|
right := time.Date(2026, 5, 27, 20, 50, 0, 0, time.UTC)
|
||||||
|
|
||||||
if !SamePeriod(left, right, PeriodWindow{Type: PeriodDay}, riyadh) {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,8 +12,10 @@ import (
|
|||||||
|
|
||||||
func (r *MySQLRepository) listSQLLuckyGiftRows(ctx context.Context, start, end time.Time,
|
func (r *MySQLRepository) listSQLLuckyGiftRows(ctx context.Context, start, end time.Time,
|
||||||
statTimezone string) ([]sqlLuckyGiftRow, error) {
|
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 := `
|
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, ''), ?),
|
t.sys_origin, IFNULL(NULLIF(u.country_code, ''), ?),
|
||||||
IFNULL(NULLIF(u.country_name, ''), IFNULL(NULLIF(u.country_code, ''), ?)),
|
IFNULL(NULLIF(u.country_name, ''), IFNULL(NULLIF(u.country_code, ''), ?)),
|
||||||
t.pay_amount, t.award_amount
|
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
|
JOIN ` + quoteIdent(r.cfg.MySQL.Schema) + `.user_base_info u ON u.id = t.user_id
|
||||||
WHERE t.create_time >= ?
|
WHERE t.create_time >= ?
|
||||||
AND t.create_time < ?
|
AND t.create_time < ?
|
||||||
AND t.sys_origin IN ` + inStringPlaceholders(len(r.cfg.Dashboard.SysOrigins)) + `
|
AND t.sys_origin IN ` + inStringPlaceholders(len(r.cfg.Dashboard.SysOrigins)) + `
|
||||||
AND u.origin_sys = t.sys_origin
|
AND u.origin_sys = t.sys_origin
|
||||||
AND (u.is_del = 0 OR u.is_del IS NULL)`
|
AND (u.is_del = 0 OR u.is_del IS NULL)`
|
||||||
args := []interface{}{timezoneOffset(statTimezone), r.cfg.Dashboard.UnknownCountryCode,
|
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)...)
|
args = append(args, stringArgs(r.cfg.Dashboard.SysOrigins)...)
|
||||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user