@@ -111,6 +111,11 @@ func (s *Service) Trend(ctx context.Context, query TrendQuery) (Trend, error) {
111111 loc := timezone .Resolve (query .TZ )
112112 now := s .now ().In (loc )
113113 startTime , endTime := resolveTrendTimeRange (query , now )
114+ // 小时粒度仅在单日窗口内可读(today / 单日 custom);跨多天时小时桶的
115+ // X 轴标签会跨天重复且点数过多,一律收敛为按天聚合。
116+ if query .Granularity == "hour" && endTime .Sub (startTime ) > maxHourlySpan {
117+ query .Granularity = "day"
118+ }
114119 cacheKey := trendCacheKey (query , loc , startTime , endTime )
115120 if trend , ok := s .loadTrendCache (ctx , cacheKey ); ok {
116121 return trend , nil
@@ -159,11 +164,12 @@ func (s *Service) loadTrendFresh(ctx context.Context, query TrendQuery, loc *tim
159164 return Trend {}, err
160165 }
161166
167+ fillKeys := trendBucketKeys (startTime , endTime , query .Granularity , loc )
162168 return Trend {
163169 ModelDistribution : aggregateModelDistribution (logs ),
164170 UserRanking : aggregateUserRanking (logs ),
165- TokenTrend : aggregateTokenTrend (logs , query .Granularity , loc ),
166- TopUsers : aggregateTopUsers (logs , query .Granularity , loc ),
171+ TokenTrend : aggregateTokenTrend (logs , query .Granularity , loc , fillKeys ),
172+ TopUsers : aggregateTopUsers (logs , query .Granularity , loc , fillKeys ),
167173 }, nil
168174}
169175
@@ -352,7 +358,7 @@ func aggregateUserRanking(logs []TrendLog) []UserRanking {
352358 return result
353359}
354360
355- func aggregateTokenTrend (logs []TrendLog , granularity string , loc * time.Location ) []TimeBucket {
361+ func aggregateTokenTrend (logs []TrendLog , granularity string , loc * time.Location , fillKeys [] string ) []TimeBucket {
356362 layout := trendTimeLayout (granularity )
357363 bucketMap := make (map [string ]* TimeBucket )
358364 for _ , item := range logs {
@@ -369,6 +375,12 @@ func aggregateTokenTrend(logs []TrendLog, granularity string, loc *time.Location
369375 bucket .ActualCost += item .ActualCost
370376 bucket .StandardCost += item .StandardCost
371377 }
378+ // 空桶零填充:没有请求的时段也要出现在折线图上,避免 category 轴压缩时间轴。
379+ for _ , key := range fillKeys {
380+ if bucketMap [key ] == nil {
381+ bucketMap [key ] = & TimeBucket {Time : key }
382+ }
383+ }
372384
373385 result := make ([]TimeBucket , 0 , len (bucketMap ))
374386 for _ , item := range bucketMap {
@@ -380,7 +392,7 @@ func aggregateTokenTrend(logs []TrendLog, granularity string, loc *time.Location
380392 return result
381393}
382394
383- func aggregateTopUsers (logs []TrendLog , granularity string , loc * time.Location ) []UserTrend {
395+ func aggregateTopUsers (logs []TrendLog , granularity string , loc * time.Location , fillKeys [] string ) []UserTrend {
384396 type userTotal struct {
385397 UserID int
386398 Email string
@@ -425,6 +437,14 @@ func aggregateTopUsers(logs []TrendLog, granularity string, loc *time.Location)
425437 }
426438 userBuckets [item.UserID ][key ] += item .InputTokens + item .OutputTokens + item .CachedInputTokens + item .CacheCreationTokens
427439 }
440+ // 空桶零填充:每个用户的折线补齐无请求时段,保证各条线共享同一条时间轴。
441+ for _ , buckets := range userBuckets {
442+ for _ , key := range fillKeys {
443+ if _ , ok := buckets [key ]; ! ok {
444+ buckets [key ] = 0
445+ }
446+ }
447+ }
428448
429449 result := make ([]UserTrend , 0 , len (totals ))
430450 for _ , item := range totals {
@@ -454,3 +474,37 @@ func trendTimeLayout(granularity string) string {
454474 }
455475 return "2006-01-02"
456476}
477+
478+ const (
479+ // maxHourlySpan 小时粒度允许的最大时间窗口(单日 + DST 余量)。
480+ maxHourlySpan = 36 * time .Hour
481+ // maxTrendFillBuckets 零填充的桶数上限;超限(如跨多年的 custom 范围)时
482+ // 放弃填充退回稀疏输出,避免响应体膨胀。
483+ maxTrendFillBuckets = 400
484+ )
485+
486+ // trendBucketKeys 生成 [startTime, endTime) 区间内完整的时间桶 key 序列(空桶零填充用)。
487+ // 超出 maxTrendFillBuckets 时返回 nil,表示跳过填充。
488+ func trendBucketKeys (startTime , endTime time.Time , granularity string , loc * time.Location ) []string {
489+ layout := trendTimeLayout (granularity )
490+ cur := startTime .In (loc )
491+ if granularity == "hour" {
492+ cur = time .Date (cur .Year (), cur .Month (), cur .Day (), cur .Hour (), 0 , 0 , 0 , loc )
493+ } else {
494+ cur = timezone .StartOfDay (cur )
495+ }
496+ end := endTime .In (loc )
497+ keys := make ([]string , 0 , 128 )
498+ for cur .Before (end ) {
499+ if len (keys ) >= maxTrendFillBuckets {
500+ return nil
501+ }
502+ keys = append (keys , cur .Format (layout ))
503+ if granularity == "hour" {
504+ cur = cur .Add (time .Hour )
505+ } else {
506+ cur = cur .AddDate (0 , 0 , 1 )
507+ }
508+ }
509+ return keys
510+ }
0 commit comments