diff --git a/README.md b/README.md index b6fceab..dcdd20f 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,18 @@ EZ-API 是"大脑"。它管理着事实的来源 (Source of Truth)。 - `POST /models`: 注册支持的模型。 - `GET /models`: 列出所有模型。 +### Feature Flags(给未来前端用) + +控制平面会把轻量“开关配置”存到 Redis 的 hash:`meta:features`,并提供管理接口: + +- `GET /admin/features` +- `PUT /admin/features`(body 为 JSON map) + +常用 flags: + +- `dp_state_store_backend`: `memory`(默认)/ `redis` +- `dp_claude_cross_upstream`: `true` / `false` + ### 系统接口 - `POST /sync/snapshot`: 强制将 DB 状态全量重新同步到 Redis。 - `POST /logs`: 供 Balancer 推送日志的内部端点 (异步)。 diff --git a/cmd/server/main.go b/cmd/server/main.go index 8457bbb..65b760b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -113,6 +113,7 @@ func main() { handler := api.NewHandler(db, syncService, logWriter) adminHandler := api.NewAdminHandler(masterService, syncService) masterHandler := api.NewMasterHandler(masterService, syncService) + featureHandler := api.NewFeatureHandler(rdb) // 4.1 Prime Redis snapshots so DP can start with data if err := syncService.SyncAll(db); err != nil { @@ -157,6 +158,8 @@ func main() { adminGroup.Use(middleware.AdminAuthMiddleware(adminService)) { adminGroup.POST("/masters", adminHandler.CreateMaster) + adminGroup.GET("/features", featureHandler.ListFeatures) + adminGroup.PUT("/features", featureHandler.UpdateFeatures) // Other admin routes for managing providers, models, etc. adminGroup.POST("/providers", handler.CreateProvider) adminGroup.POST("/models", handler.CreateModel) diff --git a/internal/api/feature_handler.go b/internal/api/feature_handler.go new file mode 100644 index 0000000..d6107bf --- /dev/null +++ b/internal/api/feature_handler.go @@ -0,0 +1,106 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +const featuresKey = "meta:features" + +// 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} gin.H +// @Failure 500 {object} 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 + } + 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} gin.H +// @Failure 400 {object} gin.H +// @Failure 500 {object} 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)) + for k, v := range req { + key := strings.TrimSpace(k) + if key == "" { + 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 { + 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 + } + + c.JSON(http.StatusOK, gin.H{"updated": updates}) +}