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
This commit is contained in:
zenfun
2025-12-21 12:26:48 +08:00
parent 25795a79d6
commit 2f58dd76a7
2 changed files with 238 additions and 4 deletions

View File

@@ -1,8 +1,11 @@
package api package api
import ( import (
"context"
"fmt" "fmt"
"math"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -10,6 +13,12 @@ import (
) )
const featuresKey = "meta:features" 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. // FeatureHandler manages lightweight feature flags stored in Redis for DP/CP runtime toggles.
// Values are stored as plain strings in a Redis hash. // 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()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read features", "details": err.Error()})
return 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}) 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)) updates := make(map[string]string, len(req))
specialUpdates := make(map[string]string, 2)
for k, v := range req { for k, v := range req {
key := strings.TrimSpace(k) key := strings.TrimSpace(k)
if key == "" { if key == "" {
continue 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) { switch vv := v.(type) {
case string: case string:
updates[key] = strings.TrimSpace(vv) 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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "no valid feature updates"})
return return
} }
if len(updates) > 0 {
if err := h.rdb.HSet(c.Request.Context(), featuresKey, updates).Err(); err != nil { 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()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update features", "details": err.Error()})
return return
} }
}
for k, v := range specialUpdates {
updates[k] = v
}
c.JSON(http.StatusOK, gin.H{"updated": updates}) 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")
}
}

View File

@@ -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"])
}
}