From 2f58dd76a7fc3be491845e895d50be21e4f0fb93 Mon Sep 17 00:00:00 2001 From: zenfun Date: Sun, 21 Dec 2025 12:26:48 +0800 Subject: [PATCH] feat(api): add log retention and max records feature overrides Add special handling for log_retention_days and log_max_records features that are stored as separate Redis keys instead of in the features hash. - Store log overrides in dedicated keys (meta:log:retention_days, meta:log:max_records) for runtime access by log cleanup cron - Include log override values in ListFeatures response - Support clearing overrides by setting value to 0 - Add comprehensive tests for log override CRUD operations --- internal/api/feature_handler.go | 101 ++++++++++++++++++- internal/api/feature_handler_test.go | 141 +++++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 internal/api/feature_handler_test.go diff --git a/internal/api/feature_handler.go b/internal/api/feature_handler.go index d6107bf..dec0c12 100644 --- a/internal/api/feature_handler.go +++ b/internal/api/feature_handler.go @@ -1,8 +1,11 @@ package api import ( + "context" "fmt" + "math" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -10,6 +13,12 @@ import ( ) const featuresKey = "meta:features" +const ( + logRetentionFeatureKey = "log_retention_days" + logMaxRecordsFeatureKey = "log_max_records" + logRetentionRedisKey = "meta:log:retention_days" + logMaxRecordsRedisKey = "meta:log:max_records" +) // FeatureHandler manages lightweight feature flags stored in Redis for DP/CP runtime toggles. // Values are stored as plain strings in a Redis hash. @@ -40,6 +49,18 @@ func (h *FeatureHandler) ListFeatures(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read features", "details": err.Error()}) return } + if v, err := h.rdb.Get(c.Request.Context(), logRetentionRedisKey).Result(); err == nil { + m[logRetentionFeatureKey] = strings.TrimSpace(v) + } else if err != redis.Nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read log retention", "details": err.Error()}) + return + } + if v, err := h.rdb.Get(c.Request.Context(), logMaxRecordsRedisKey).Result(); err == nil { + m[logMaxRecordsFeatureKey] = strings.TrimSpace(v) + } else if err != redis.Nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read log max records", "details": err.Error()}) + return + } c.JSON(http.StatusOK, gin.H{"features": m}) } @@ -70,11 +91,30 @@ func (h *FeatureHandler) UpdateFeatures(c *gin.Context) { } updates := make(map[string]string, len(req)) + specialUpdates := make(map[string]string, 2) for k, v := range req { key := strings.TrimSpace(k) if key == "" { continue } + switch key { + case logRetentionFeatureKey: + value, err := updateLogOverride(c.Request.Context(), h.rdb, logRetentionRedisKey, v) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + specialUpdates[key] = value + continue + case logMaxRecordsFeatureKey: + value, err := updateLogOverride(c.Request.Context(), h.rdb, logMaxRecordsRedisKey, v) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + specialUpdates[key] = value + continue + } switch vv := v.(type) { case string: updates[key] = strings.TrimSpace(vv) @@ -92,15 +132,68 @@ func (h *FeatureHandler) UpdateFeatures(c *gin.Context) { } } - if len(updates) == 0 { + if len(updates) == 0 && len(specialUpdates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no valid feature updates"}) return } - if err := h.rdb.HSet(c.Request.Context(), featuresKey, updates).Err(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update features", "details": err.Error()}) - return + if len(updates) > 0 { + if err := h.rdb.HSet(c.Request.Context(), featuresKey, updates).Err(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update features", "details": err.Error()}) + return + } } + for k, v := range specialUpdates { + updates[k] = v + } c.JSON(http.StatusOK, gin.H{"updated": updates}) } + +func updateLogOverride(ctx context.Context, rdb *redis.Client, key string, value any) (string, error) { + n, err := parsePositiveInt64(value) + if err != nil { + return "", err + } + if n <= 0 { + if err := rdb.Del(ctx, key).Err(); err != nil && err != redis.Nil { + return "", fmt.Errorf("failed to clear %s: %w", key, err) + } + return "0", nil + } + raw := strconv.FormatInt(n, 10) + if err := rdb.Set(ctx, key, raw, 0).Err(); err != nil { + return "", fmt.Errorf("failed to update %s: %w", key, err) + } + return raw, nil +} + +func parsePositiveInt64(value any) (int64, error) { + switch v := value.(type) { + case nil: + return 0, nil + case float64: + if v < 0 { + return 0, fmt.Errorf("value must be >= 0") + } + if v != math.Trunc(v) { + return 0, fmt.Errorf("value must be an integer") + } + return int64(v), nil + case string: + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return 0, nil + } + n, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil { + return 0, fmt.Errorf("value must be an integer") + } + if n < 0 { + return 0, fmt.Errorf("value must be >= 0") + } + return n, nil + default: + return 0, fmt.Errorf("value must be an integer") + } +} diff --git a/internal/api/feature_handler_test.go b/internal/api/feature_handler_test.go new file mode 100644 index 0000000..0419c6e --- /dev/null +++ b/internal/api/feature_handler_test.go @@ -0,0 +1,141 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +func TestFeatureHandler_UpdateFeatures_LogOverrides(t *testing.T) { + gin.SetMode(gin.TestMode) + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + + h := NewFeatureHandler(rdb) + r := gin.New() + r.PUT("/admin/features", h.UpdateFeatures) + + body := []byte(`{"log_retention_days":7,"log_max_records":123}`) + req := httptest.NewRequest(http.MethodPut, "/admin/features", bytes.NewReader(body)) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + + type resp struct { + Updated map[string]string `json:"updated"` + } + var got resp + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Updated["log_retention_days"] != "7" { + t.Fatalf("expected log_retention_days=7, got %q", got.Updated["log_retention_days"]) + } + if got.Updated["log_max_records"] != "123" { + t.Fatalf("expected log_max_records=123, got %q", got.Updated["log_max_records"]) + } + + if v, _ := mr.Get("meta:log:retention_days"); v != "7" { + t.Fatalf("expected redis retention_days=7, got %q", v) + } + if v, _ := mr.Get("meta:log:max_records"); v != "123" { + t.Fatalf("expected redis max_records=123, got %q", v) + } + if v := mr.HGet("meta:features", "log_retention_days"); v != "" { + t.Fatalf("expected no log_retention_days in meta:features, got %q", v) + } + if v := mr.HGet("meta:features", "log_max_records"); v != "" { + t.Fatalf("expected no log_max_records in meta:features, got %q", v) + } +} + +func TestFeatureHandler_UpdateFeatures_LogOverridesClear(t *testing.T) { + gin.SetMode(gin.TestMode) + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + + if err := rdb.Set(context.Background(), "meta:log:retention_days", "9", 0).Err(); err != nil { + t.Fatalf("seed retention: %v", err) + } + if err := rdb.Set(context.Background(), "meta:log:max_records", "999", 0).Err(); err != nil { + t.Fatalf("seed max_records: %v", err) + } + + h := NewFeatureHandler(rdb) + r := gin.New() + r.PUT("/admin/features", h.UpdateFeatures) + + body := []byte(`{"log_retention_days":0,"log_max_records":0}`) + req := httptest.NewRequest(http.MethodPut, "/admin/features", bytes.NewReader(body)) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + if mr.Exists("meta:log:retention_days") { + t.Fatalf("expected retention_days to be cleared") + } + if mr.Exists("meta:log:max_records") { + t.Fatalf("expected max_records to be cleared") + } +} + +func TestFeatureHandler_ListFeatures_IncludesLogOverrides(t *testing.T) { + gin.SetMode(gin.TestMode) + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + + if err := rdb.HSet(context.Background(), "meta:features", map[string]any{ + "registration_code_enabled": "true", + }).Err(); err != nil { + t.Fatalf("seed features: %v", err) + } + if err := rdb.Set(context.Background(), "meta:log:retention_days", "14", 0).Err(); err != nil { + t.Fatalf("seed retention: %v", err) + } + if err := rdb.Set(context.Background(), "meta:log:max_records", "2048", 0).Err(); err != nil { + t.Fatalf("seed max_records: %v", err) + } + + h := NewFeatureHandler(rdb) + r := gin.New() + r.GET("/admin/features", h.ListFeatures) + + req := httptest.NewRequest(http.MethodGet, "/admin/features", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + + var got struct { + Features map[string]string `json:"features"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Features["registration_code_enabled"] != "true" { + t.Fatalf("expected registration_code_enabled=true, got %q", got.Features["registration_code_enabled"]) + } + if got.Features["log_retention_days"] != "14" { + t.Fatalf("expected log_retention_days=14, got %q", got.Features["log_retention_days"]) + } + if got.Features["log_max_records"] != "2048" { + t.Fatalf("expected log_max_records=2048, got %q", got.Features["log_max_records"]) + } +}