feat(api): add feature flag management endpoints

Add FeatureHandler to manage lightweight runtime configuration toggles
stored in the Redis `meta:features` hash. This enables dynamic control
over system behavior (e.g., storage backends) via the admin API.

- Add `GET /admin/features` to list flags
- Add `PUT /admin/features` to update flags
- Update README with feature flag documentation
This commit is contained in:
zenfun
2025-12-15 15:01:01 +08:00
parent fb68e0f155
commit 11f6e81798
3 changed files with 121 additions and 0 deletions

View File

@@ -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 推送日志的内部端点 (异步)。

View File

@@ -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)

View File

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