diff --git a/internal/api/admin/dashboard_admin.go b/internal/api/admin/dashboard_admin.go index 8c74b3e..db73610 100755 --- a/internal/api/admin/dashboard_admin.go +++ b/internal/api/admin/dashboard_admin.go @@ -761,11 +761,11 @@ func parseCustomDateRange(startS, endS string) (time.Time, time.Time, bool) { return time.Time{}, time.Time{}, false } - st, err := time.Parse("2006-01-02", startS) + st, err := time.ParseInLocation("2006-01-02", startS, time.Local) if err != nil { return time.Time{}, time.Time{}, false } - et, err := time.Parse("2006-01-02", endS) + et, err := time.ParseInLocation("2006-01-02", endS, time.Local) if err != nil { return time.Time{}, time.Time{}, false } @@ -773,9 +773,9 @@ func parseCustomDateRange(startS, endS string) (time.Time, time.Time, bool) { return time.Time{}, time.Time{}, false } - et = et.Add(24 * time.Hour).Add(-time.Second) - if et.Sub(st) > 365*24*time.Hour { - et = st.Add(365 * 24 * time.Hour).Add(-time.Second) + et = et.Add(24 * time.Hour) + if et.Sub(st) > 366*24*time.Hour { + et = st.Add(366 * 24 * time.Hour) } return st, et, true } @@ -907,9 +907,10 @@ func daysBetween(start, end time.Time) []time.Time { } type bucket struct { - Label string - Start time.Time - End time.Time + Label string + Start time.Time + End time.Time + EndExclusive time.Time } func normalizeGranularity(in string) string { @@ -929,12 +930,17 @@ func normalizeGranularity(in string) string { func buildBuckets(start, end time.Time, gran string) []bucket { if gran == "day" { - days := daysBetween(start, end) + days := daysBetween(start, end.Add(-time.Second)) out := make([]bucket, 0, len(days)) for _, d := range days { ds := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) - de := ds.Add(24 * time.Hour).Add(-time.Second) - out = append(out, bucket{Label: ds.Format("2006-01-02"), Start: ds, End: de}) + de := ds.Add(24 * time.Hour) + out = append(out, bucket{ + Label: ds.Format("2006-01-02"), + Start: ds, + End: de.Add(-time.Second), + EndExclusive: de, + }) } return out } @@ -944,15 +950,22 @@ func buildBuckets(start, end time.Time, gran string) []bucket { s = s.Add(-24 * time.Hour) } out := []bucket{} - for cur := s; cur.Before(end) || cur.Equal(end); cur = cur.Add(7 * 24 * time.Hour) { + for cur := s; cur.Before(end); cur = cur.Add(7 * 24 * time.Hour) { ds := time.Date(cur.Year(), cur.Month(), cur.Day(), 0, 0, 0, 0, cur.Location()) - de := ds.Add(7 * 24 * time.Hour).Add(-time.Second) + de := ds.Add(7 * 24 * time.Hour) + endInclusive := de.Add(-time.Second) label := ds.Format("2006-01-02") if de.After(end) { de = end + endInclusive = end } - out = append(out, bucket{Label: label, Start: ds, End: de}) - if de.Equal(end) { + out = append(out, bucket{ + Label: label, + Start: ds, + End: endInclusive, + EndExclusive: de, + }) + if !de.Before(end) { break } } @@ -960,35 +973,60 @@ func buildBuckets(start, end time.Time, gran string) []bucket { } if gran == "hour" { out := []bucket{} - // 步长 1 小时 - for cur := start; !cur.After(end); cur = cur.Add(time.Hour) { + for cur := start; cur.Before(end); cur = cur.Add(time.Hour) { s := time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), 0, 0, 0, cur.Location()) - de := s.Add(time.Hour).Add(-time.Second) - out = append(out, bucket{Label: s.Format("01-02 15h"), Start: s, End: de}) + de := s.Add(time.Hour) + endInclusive := de.Add(-time.Second) + if de.After(end) { + de = end + endInclusive = end + } + out = append(out, bucket{ + Label: s.Format("01-02 15h"), + Start: s, + End: endInclusive, + EndExclusive: de, + }) } return out } if gran == "minute" { out := []bucket{} - // 步长 1 分钟 - for cur := start; !cur.After(end); cur = cur.Add(time.Minute) { + for cur := start; cur.Before(end); cur = cur.Add(time.Minute) { s := time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), cur.Minute(), 0, 0, cur.Location()) - de := s.Add(time.Minute).Add(-time.Second) - out = append(out, bucket{Label: s.Format("01-02 15:04"), Start: s, End: de}) + de := s.Add(time.Minute) + endInclusive := de.Add(-time.Second) + if de.After(end) { + de = end + endInclusive = end + } + out = append(out, bucket{ + Label: s.Format("01-02 15:04"), + Start: s, + End: endInclusive, + EndExclusive: de, + }) } return out } s := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, start.Location()) out := []bucket{} - for cur := s; cur.Before(end) || cur.Equal(end); cur = cur.AddDate(0, 1, 0) { + for cur := s; cur.Before(end); cur = cur.AddDate(0, 1, 0) { ds := time.Date(cur.Year(), cur.Month(), 1, 0, 0, 0, 0, cur.Location()) - de := ds.AddDate(0, 1, 0).Add(-time.Second) + de := ds.AddDate(0, 1, 0) + endInclusive := de.Add(-time.Second) label := ds.Format("2006-01") if de.After(end) { de = end + endInclusive = end } - out = append(out, bucket{Label: label, Start: ds, End: de}) - if de.Equal(end) { + out = append(out, bucket{ + Label: label, + Start: ds, + End: endInclusive, + EndExclusive: de, + }) + if !de.Before(end) { break } } @@ -1771,7 +1809,7 @@ func (h *handler) DashboardSalesDrawTrend() core.HandlerFunc { Where("source_type IN ?", []int32{2, 3, 4}). Where("(ext_order_id = '' OR ext_order_id IS NULL)"). Where("paid_at >= ?", b.Start). - Where("paid_at <= ?", b.End). + Where("paid_at < ?", b.EndExclusive). Select("COUNT(id) as orders, COALESCE(SUM(actual_amount + discount_amount), 0) as gmv, COALESCE(SUM(actual_amount), 0) as paid_amount"). Scan(&orderStats) diff --git a/internal/api/admin/dashboard_admin_test.go b/internal/api/admin/dashboard_admin_test.go index 08a547e..c1e4190 100755 --- a/internal/api/admin/dashboard_admin_test.go +++ b/internal/api/admin/dashboard_admin_test.go @@ -7,6 +7,23 @@ import ( "time" ) +func useShanghaiLocal(t *testing.T) *time.Location { + t.Helper() + + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf("load Asia/Shanghai: %v", err) + } + + oldLocal := time.Local + time.Local = loc + t.Cleanup(func() { + time.Local = oldLocal + }) + + return loc +} + func TestPercentChange(t *testing.T) { if s := percentChange(0, 10); s != "+0%" { t.Fatalf("prev0") @@ -38,13 +55,16 @@ func TestDaysBetween(t *testing.T) { } func TestParseSalesTrendRange_PrefersStartEnd(t *testing.T) { + loc := useShanghaiLocal(t) now := time.Date(2026, 3, 3, 12, 0, 0, 0, time.UTC) s, e := parseSalesTrendRange("week", "2026-01-01", "2026-01-10", now, nil) - if !s.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) { + expectedStart := time.Date(2026, 1, 1, 0, 0, 0, 0, loc) + expectedEnd := time.Date(2026, 1, 11, 0, 0, 0, 0, loc) + if !s.Equal(expectedStart) { t.Fatalf("unexpected start: %v", s) } - if !e.Equal(time.Date(2026, 1, 10, 23, 59, 59, 0, time.UTC)) { + if !e.Equal(expectedEnd) { t.Fatalf("unexpected end: %v", e) } } @@ -87,11 +107,12 @@ func TestParseSalesTrendRange_AliasAndAllFallback(t *testing.T) { } func TestParseSalesTrendRange_CustomSpanCap(t *testing.T) { + loc := useShanghaiLocal(t) now := time.Date(2026, 3, 3, 12, 0, 0, 0, time.UTC) s, e := parseSalesTrendRange("custom", "2025-01-01", "2026-12-31", now, nil) - expectedStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - expectedEnd := expectedStart.Add(365 * 24 * time.Hour).Add(-time.Second) + expectedStart := time.Date(2025, 1, 1, 0, 0, 0, 0, loc) + expectedEnd := expectedStart.Add(366 * 24 * time.Hour) if !s.Equal(expectedStart) { t.Fatalf("unexpected custom start: %v", s) } @@ -100,6 +121,21 @@ func TestParseSalesTrendRange_CustomSpanCap(t *testing.T) { } } +func TestParseSalesTrendRange_CustomSingleDayLocal(t *testing.T) { + loc := useShanghaiLocal(t) + now := time.Date(2026, 3, 3, 12, 0, 0, 0, time.UTC) + s, e := parseSalesTrendRange("custom", "2026-03-01", "2026-03-01", now, nil) + + expectedStart := time.Date(2026, 3, 1, 0, 0, 0, 0, loc) + expectedEnd := time.Date(2026, 3, 2, 0, 0, 0, 0, loc) + if !s.Equal(expectedStart) { + t.Fatalf("unexpected custom single-day start: %v", s) + } + if !e.Equal(expectedEnd) { + t.Fatalf("unexpected custom single-day end: %v", e) + } +} + func TestShouldUseMonthlyGranularityForAll(t *testing.T) { start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) endLong := start.Add(181 * 24 * time.Hour) @@ -119,6 +155,28 @@ func TestShouldUseMonthlyGranularityForAll(t *testing.T) { } } +func TestBuildBuckets_DayUsesHalfOpenInterval(t *testing.T) { + loc := useShanghaiLocal(t) + start := time.Date(2026, 3, 1, 0, 0, 0, 0, loc) + endExclusive := time.Date(2026, 3, 2, 0, 0, 0, 0, loc) + + buckets := buildBuckets(start, endExclusive, "day") + if len(buckets) != 1 { + t.Fatalf("expected 1 bucket, got %d", len(buckets)) + } + + b := buckets[0] + if !b.Start.Equal(start) { + t.Fatalf("unexpected bucket start: %v", b.Start) + } + if !b.End.Equal(time.Date(2026, 3, 1, 23, 59, 59, 0, loc)) { + t.Fatalf("unexpected bucket inclusive end: %v", b.End) + } + if !b.EndExclusive.Equal(endExclusive) { + t.Fatalf("unexpected bucket exclusive end: %v", b.EndExclusive) + } +} + func TestTrendPointJSONIncludesPaidAmount(t *testing.T) { point := trendPoint{ Date: "2026-03-01",