diff --git a/backend/services/daily_volume.go b/backend/services/daily_volume.go index 76a2793..3fe1b51 100644 --- a/backend/services/daily_volume.go +++ b/backend/services/daily_volume.go @@ -301,24 +301,49 @@ func CaptureRecentDailyVolumes() { pruneDailyVolumeSnapshots() } -// pruneDailyVolumeSnapshots 丢弃超过保留期的历史。 +// pruneDailyVolumeSnapshots 丢弃超过保留期的历史,并修正历史遗留的 partial 标记。 func pruneDailyVolumeSnapshots() { db := repository.GetDB() if db == nil { return } cutoff := dateKey(time.Now().AddDate(0, 0, -dailyVolumeRetentionDays)) + today := dateKey(time.Now()) db.Lock() defer db.Unlock() + changed := false kept := make([]models.DailyVolumeSnapshot, 0, len(db.DailyVolumeSnapshots)) for _, snap := range db.DailyVolumeSnapshots { - if snap.Date >= cutoff { - kept = append(kept, snap) + if snap.Date < cutoff { + continue } + kept = append(kept, snap) } if len(kept) != len(db.DailyVolumeSnapshots) { log.Printf("[daily-volume] 清理过期快照 %d 条", len(db.DailyVolumeSnapshots)-len(kept)) db.DailyVolumeSnapshots = kept + changed = true + } + + // 历史遗留修正:**过去的天不可能"还在进行中"**。 + // 早期版本把「起点超出 24h」也当成 partial,于是昨天/前天的柱子一直是虚线空心, + // 看起来像"每天都在采样中"。只有当快照是在那天**结束之后**才抓的 + // (说明抓的是完整一天)才改成完整;当天中途抓到的继续保持 partial —— 那时确实只有半天。 + for i := range db.DailyVolumeSnapshots { + snap := &db.DailyVolumeSnapshots[i] + if !snap.Partial || snap.Date >= today { + continue + } + day, err := time.ParseInLocation("2006-01-02", snap.Date, time.Local) + if err != nil { + continue + } + if _, dayEnd := dayRange(day); snap.CapturedAt.UnixNano() >= dayEnd { + snap.Partial = false + changed = true + } + } + if changed { db.RequestSave() } } diff --git a/backend/services/daily_volume_test.go b/backend/services/daily_volume_test.go new file mode 100644 index 0000000..efac0bb --- /dev/null +++ b/backend/services/daily_volume_test.go @@ -0,0 +1,136 @@ +package services + +import ( + "testing" + "time" + + "lokistack/models" + "lokistack/repository" +) + +func setupDailyVolumeDB(t *testing.T) { + t.Helper() + t.Setenv("DATA_DIR", t.TempDir()) + t.Setenv("JWT_SECRET", "test-jwt-secret-with-at-least-32-characters") + t.Setenv("ENCRYPTION_KEY", "test-encryption-key-with-at-least-32-characters") + if err := repository.InitDB(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = repository.Shutdown() }) +} + +func snapshotsByDate(t *testing.T) map[string]models.DailyVolumeSnapshot { + t.Helper() + got := map[string]models.DailyVolumeSnapshot{} + db := repository.GetDB() + db.RLock() + defer db.RUnlock() + for _, snap := range db.DailyVolumeSnapshots { + got[snap.Date] = snap + } + return got +} + +// 过去的天不可能"还在进行中":只有在那天结束之后才抓的快照才算完整一天, +// 当天中途抓到的必须保持 partial —— 否则会把"只抓到半天"伪装成完整数据。 +func TestPruneDailyVolumeNormalizesPartialAndDropsExpired(t *testing.T) { + setupDailyVolumeDB(t) + now := time.Now() + midday := func(d time.Time) time.Time { + return time.Date(d.Year(), d.Month(), d.Day(), 12, 0, 0, 0, time.Local) + } + yesterday := now.AddDate(0, 0, -1) + dayBefore := now.AddDate(0, 0, -2) + expired := now.AddDate(0, 0, -(dailyVolumeRetentionDays + 5)) + + db := repository.GetDB() + db.Lock() + db.DailyVolumeSnapshots = []models.DailyVolumeSnapshot{ + // 昨天结束之后(次日 12:00)抓的 → 完整一天 + {ID: 1, Date: dateKey(yesterday), SourceID: 1, Bytes: 100, Entries: 7, + Partial: true, CapturedAt: midday(now)}, + // 前天中午抓的 → 只有半天,保持 partial + {ID: 2, Date: dateKey(dayBefore), SourceID: 1, Bytes: 50, + Partial: true, CapturedAt: midday(dayBefore)}, + // 今天 → 永远 partial + {ID: 3, Date: dateKey(now), SourceID: 1, Bytes: 10, Partial: true}, + // 超过留存期 → 必须被清理 + {ID: 4, Date: dateKey(expired), SourceID: 1, Bytes: 999}, + } + db.Unlock() + + pruneDailyVolumeSnapshots() + + got := snapshotsByDate(t) + if _, ok := got[dateKey(expired)]; ok { + t.Error("超过留存期的快照没有被清理") + } + if snap := got[dateKey(yesterday)]; snap.Partial { + t.Error("昨天结束之后抓的快照应标记为完整(partial=false)") + } + if snap := got[dateKey(dayBefore)]; !snap.Partial { + t.Error("当天中途抓的快照必须保持 partial=true") + } + if snap := got[dateKey(now)]; !snap.Partial { + t.Error("今天必须保持 partial=true") + } + if snap := got[dateKey(yesterday)]; snap.Entries != 7 { + t.Errorf("条数不应被清理流程清空,得到 %d", snap.Entries) + } +} + +// 刷新体积不能把已经抓到的条数(贵、每天最多 3 次)冲掉。 +func TestUpsertVolumeSnapshotKeepsEntries(t *testing.T) { + setupDailyVolumeDB(t) + + db := repository.GetDB() + db.Lock() + db.DailyVolumeSnapshots = []models.DailyVolumeSnapshot{ + {ID: 1, Date: "2026-10-01", SourceID: 1, Bytes: 111, Entries: 42, Partial: true}, + } + db.Unlock() + + if err := upsertVolumeSnapshot(1, "2026-10-01", 1000, false); err != nil { + t.Fatal(err) + } + got := snapshotsByDate(t)["2026-10-01"] + if got.Bytes != 1000 || got.Entries != 42 || got.Partial { + t.Fatalf("更新后 bytes=%d entries=%d partial=%v;期望 1000/42/false", got.Bytes, got.Entries, got.Partial) + } + + if err := upsertVolumeSnapshot(1, "2026-10-02", 2000, true); err != nil { + t.Fatal(err) + } + if got := snapshotsByDate(t); len(got) != 2 { + t.Fatalf("新增一天后应有 2 条快照,得到 %d", len(got)) + } +} + +// 接口必须始终返回 days 个点,缺的标 Missing("不知道"),绝不伪造 0("确实没日志")。 +func TestGetDailyVolumeHistoryReturnsAllDaysWithMissing(t *testing.T) { + setupDailyVolumeDB(t) + now := time.Now() + + db := repository.GetDB() + db.Lock() + db.DailyVolumeSnapshots = []models.DailyVolumeSnapshot{ + {ID: 1, Date: dateKey(now), SourceID: 1, Bytes: 123, Partial: true}, + {ID: 2, Date: dateKey(now), SourceID: 2, Bytes: 999}, // 别的数据源,不应混进来 + } + db.Unlock() + + points := GetDailyVolumeHistory(1, 7) + if len(points) != 7 { + t.Fatalf("期望 7 个点,得到 %d", len(points)) + } + today := points[len(points)-1] + if today.Missing || today.Bytes != 123 || !today.Partial { + t.Errorf("今天应命中快照:missing=%v bytes=%d partial=%v", today.Missing, today.Bytes, today.Partial) + } + for i := 0; i < len(points)-1; i++ { + if !points[i].Missing || points[i].Bytes != 0 { + t.Errorf("%s 应标记 Missing 且 bytes=0,得到 missing=%v bytes=%d", + points[i].Date, points[i].Missing, points[i].Bytes) + } + } +}