Files
ez-api/internal/api/feature_handler.go
zenfun 33838b1e2c feat(api): wrap JSON responses in envelope
Add response envelope middleware to standardize JSON responses as
`{code,data,message}` with consistent business codes across endpoints.
Update Swagger annotations and tests to reflect the new response shape.

BREAKING CHANGE: API responses are now wrapped in a response envelope; clients must read payloads from `data` and handle `code`/`message` fields.
2026-01-10 00:15:08 +08:00

201 lines
5.7 KiB
Go

package api
import (
"context"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const featuresKey = "meta:features"
const (
logRetentionFeatureKey = "log_retention_days"
logMaxRecordsFeatureKey = "log_max_records"
logRequestBodyFeatureKey = "log_request_body_enabled"
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.
type FeatureHandler struct {
rdb *redis.Client
}
func NewFeatureHandler(rdb *redis.Client) *FeatureHandler {
return &FeatureHandler{rdb: rdb}
}
// ListFeatures godoc
// @Summary List feature flags
// @Description Returns all feature flags stored in Redis (meta:features)
// @Tags admin
// @Produce json
// @Security AdminAuth
// @Success 200 {object} ResponseEnvelope{data=gin.H}
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
// @Router /admin/features [get]
func (h *FeatureHandler) ListFeatures(c *gin.Context) {
if h.rdb == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "redis not configured"})
return
}
m, err := h.rdb.HGetAll(c.Request.Context(), featuresKey).Result()
if err != nil {
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})
}
type UpdateFeaturesRequest map[string]any
// UpdateFeatures godoc
// @Summary Update feature flags
// @Description Updates selected feature flags (meta:features). Values are stored as strings.
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminAuth
// @Param request body object true "Feature map"
// @Success 200 {object} ResponseEnvelope{data=gin.H}
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
// @Router /admin/features [put]
func (h *FeatureHandler) UpdateFeatures(c *gin.Context) {
if h.rdb == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "redis not configured"})
return
}
var req UpdateFeaturesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
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)
case bool:
if vv {
updates[key] = "true"
} else {
updates[key] = "false"
}
case float64:
// JSON numbers decode as float64; keep as integer-looking string when possible.
updates[key] = fmt.Sprintf("%v", vv)
default:
updates[key] = fmt.Sprintf("%v", vv)
}
}
if len(updates) == 0 && len(specialUpdates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid feature updates"})
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")
}
}