From fa7f92c6e3c379c78e5e389028661a972cc07849 Mon Sep 17 00:00:00 2001 From: zenfun Date: Sun, 21 Dec 2025 23:32:07 +0800 Subject: [PATCH] feat(api): allow batch status updates --- internal/api/admin_batch_handler_test.go | 83 +++++++++++ internal/api/admin_master_ops.go | 25 ++++ internal/api/batch_handler.go | 154 +++++++++++++++++--- internal/api/model_handler_test.go | 41 ++++++ internal/api/provider_admin_handler_test.go | 52 +++++++ 5 files changed, 337 insertions(+), 18 deletions(-) create mode 100644 internal/api/admin_batch_handler_test.go diff --git a/internal/api/admin_batch_handler_test.go b/internal/api/admin_batch_handler_test.go new file mode 100644 index 0000000..f048627 --- /dev/null +++ b/internal/api/admin_batch_handler_test.go @@ -0,0 +1,83 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/ez-api/ez-api/internal/model" + "github.com/ez-api/ez-api/internal/service" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newTestAdminHandler(t *testing.T) (*AdminHandler, *gorm.DB, *miniredis.Miniredis) { + t.Helper() + gin.SetMode(gin.TestMode) + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.Master{}, &model.Key{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + sync := service.NewSyncService(rdb) + masterService := service.NewMasterService(db) + return NewAdminHandler(db, db, masterService, sync, nil), db, mr +} + +func TestAdmin_BatchMasters_Status(t *testing.T) { + h, db, mr := newTestAdminHandler(t) + + m := &model.Master{ + Name: "m1", + Group: "default", + Status: "active", + Epoch: 1, + MaxChildKeys: 5, + GlobalQPS: 3, + } + if err := db.Create(m).Error; err != nil { + t.Fatalf("create master: %v", err) + } + + r := gin.New() + r.POST("/admin/masters/batch", h.BatchMasters) + + payload := map[string]any{ + "action": "status", + "status": "suspended", + "ids": []uint{m.ID}, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/admin/masters/batch", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + 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 updated model.Master + if err := db.First(&updated, m.ID).Error; err != nil { + t.Fatalf("reload master: %v", err) + } + if updated.Status != "suspended" { + t.Fatalf("expected status suspended, got %q", updated.Status) + } + if got := mr.HGet(fmt.Sprintf("auth:master:%d", m.ID), "status"); got != "suspended" { + t.Fatalf("expected redis status suspended, got %q", got) + } +} diff --git a/internal/api/admin_master_ops.go b/internal/api/admin_master_ops.go index 6bb8807..47704a0 100644 --- a/internal/api/admin_master_ops.go +++ b/internal/api/admin_master_ops.go @@ -2,6 +2,7 @@ package api import ( "errors" + "strings" "github.com/ez-api/ez-api/internal/model" "gorm.io/gorm" @@ -49,6 +50,30 @@ func (h *AdminHandler) revokeMasterByID(id uint) error { return nil } +func (h *AdminHandler) setMasterStatusByID(id uint, status string) error { + if id == 0 { + return gorm.ErrRecordNotFound + } + status = strings.ToLower(strings.TrimSpace(status)) + if status == "" { + return errors.New("status required") + } + var m model.Master + if err := h.db.First(&m, id).Error; err != nil { + return err + } + if err := h.db.Model(&m).Update("status", status).Error; err != nil { + return err + } + if err := h.db.First(&m, id).Error; err != nil { + return err + } + if err := h.syncService.SyncMaster(&m); err != nil { + return err + } + return nil +} + func isRecordNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) } diff --git a/internal/api/batch_handler.go b/internal/api/batch_handler.go index e1f8de9..78730de 100644 --- a/internal/api/batch_handler.go +++ b/internal/api/batch_handler.go @@ -13,6 +13,7 @@ import ( type BatchActionRequest struct { Action string `json:"action"` IDs []uint `json:"ids"` + Status string `json:"status,omitempty"` } type BatchResult struct { @@ -34,6 +35,32 @@ func normalizeBatchAction(raw string) string { return raw } +func normalizeStatus(raw string) string { + return strings.ToLower(strings.TrimSpace(raw)) +} + +func isAllowedStatus(raw string, allowed ...string) bool { + raw = normalizeStatus(raw) + for _, cand := range allowed { + if raw == cand { + return true + } + } + return false +} + +// BatchMasters godoc +// @Summary Batch masters +// @Description Batch delete or status update for masters +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param request body BatchActionRequest true "Batch payload" +// @Success 200 {object} BatchResponse +// @Failure 400 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/masters/batch [post] func (h *AdminHandler) BatchMasters(c *gin.Context) { var req BatchActionRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -41,7 +68,7 @@ func (h *AdminHandler) BatchMasters(c *gin.Context) { return } action := normalizeBatchAction(req.Action) - if action != "delete" { + if action != "delete" && action != "status" { c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported action"}) return } @@ -51,8 +78,20 @@ func (h *AdminHandler) BatchMasters(c *gin.Context) { } resp := BatchResponse{Action: action} + status := normalizeStatus(req.Status) + if action == "status" && !isAllowedStatus(status, "active", "suspended") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"}) + return + } for _, id := range req.IDs { - if err := h.revokeMasterByID(id); err != nil { + var err error + switch action { + case "delete": + err = h.revokeMasterByID(id) + case "status": + err = h.setMasterStatusByID(id, status) + } + if err != nil { if isRecordNotFound(err) { resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: "not found"}) continue @@ -65,6 +104,18 @@ func (h *AdminHandler) BatchMasters(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// BatchProviders godoc +// @Summary Batch providers +// @Description Batch delete or status update for providers +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param request body BatchActionRequest true "Batch payload" +// @Success 200 {object} BatchResponse +// @Failure 400 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/providers/batch [post] func (h *Handler) BatchProviders(c *gin.Context) { var req BatchActionRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -72,7 +123,7 @@ func (h *Handler) BatchProviders(c *gin.Context) { return } action := normalizeBatchAction(req.Action) - if action != "delete" { + if action != "delete" && action != "status" { c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported action"}) return } @@ -82,6 +133,11 @@ func (h *Handler) BatchProviders(c *gin.Context) { } resp := BatchResponse{Action: action} + status := normalizeStatus(req.Status) + if action == "status" && !isAllowedStatus(status, "active", "auto_disabled", "manual_disabled") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"}) + return + } needsBindingSync := false for _, id := range req.IDs { var p model.Provider @@ -93,16 +149,39 @@ func (h *Handler) BatchProviders(c *gin.Context) { resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) continue } - if err := h.db.Delete(&p).Error; err != nil { - resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) - continue + switch action { + case "delete": + if err := h.db.Delete(&p).Error; err != nil { + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) + continue + } + if err := h.sync.SyncProviderDelete(&p); err != nil { + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) + continue + } + resp.Success = append(resp.Success, id) + needsBindingSync = true + case "status": + update := map[string]any{"status": status} + if status == "active" { + update["ban_reason"] = "" + update["ban_until"] = nil + } + if err := h.db.Model(&p).Updates(update).Error; err != nil { + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) + continue + } + if err := h.db.First(&p, id).Error; err != nil { + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) + continue + } + if err := h.sync.SyncProvider(&p); err != nil { + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) + continue + } + resp.Success = append(resp.Success, id) + needsBindingSync = true } - if err := h.sync.SyncProviderDelete(&p); err != nil { - resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) - continue - } - resp.Success = append(resp.Success, id) - needsBindingSync = true } if needsBindingSync { if err := h.sync.SyncBindings(h.db); err != nil { @@ -113,6 +192,18 @@ func (h *Handler) BatchProviders(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// BatchModels godoc +// @Summary Batch models +// @Description Batch delete for models +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param request body BatchActionRequest true "Batch payload" +// @Success 200 {object} BatchResponse +// @Failure 400 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/models/batch [post] func (h *Handler) BatchModels(c *gin.Context) { var req BatchActionRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -153,6 +244,18 @@ func (h *Handler) BatchModels(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// BatchBindings godoc +// @Summary Batch bindings +// @Description Batch delete or status update for bindings +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param request body BatchActionRequest true "Batch payload" +// @Success 200 {object} BatchResponse +// @Failure 400 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/bindings/batch [post] func (h *Handler) BatchBindings(c *gin.Context) { var req BatchActionRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -160,7 +263,7 @@ func (h *Handler) BatchBindings(c *gin.Context) { return } action := normalizeBatchAction(req.Action) - if action != "delete" { + if action != "delete" && action != "status" { c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported action"}) return } @@ -171,6 +274,11 @@ func (h *Handler) BatchBindings(c *gin.Context) { resp := BatchResponse{Action: action} needsSync := false + status := normalizeStatus(req.Status) + if action == "status" && status == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "status required"}) + return + } for _, id := range req.IDs { var b model.Binding if err := h.db.First(&b, id).Error; err != nil { @@ -181,12 +289,22 @@ func (h *Handler) BatchBindings(c *gin.Context) { resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) continue } - if err := h.db.Delete(&b).Error; err != nil { - resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) - continue + switch action { + case "delete": + if err := h.db.Delete(&b).Error; err != nil { + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) + continue + } + resp.Success = append(resp.Success, id) + needsSync = true + case "status": + if err := h.db.Model(&b).Update("status", status).Error; err != nil { + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) + continue + } + resp.Success = append(resp.Success, id) + needsSync = true } - resp.Success = append(resp.Success, id) - needsSync = true } if needsSync { if err := h.sync.SyncBindings(h.db); err != nil { diff --git a/internal/api/model_handler_test.go b/internal/api/model_handler_test.go index 00a1ab9..216354f 100644 --- a/internal/api/model_handler_test.go +++ b/internal/api/model_handler_test.go @@ -200,3 +200,44 @@ func TestBatchModels_Delete(t *testing.T) { t.Fatalf("expected meta removed, got %q", got) } } + +func TestBatchBindings_Status(t *testing.T) { + h, db := newTestHandler(t) + + b := &model.Binding{ + Namespace: "ns", + PublicModel: "m1", + RouteGroup: "default", + SelectorType: "exact", + SelectorValue: "m1", + Status: "active", + } + if err := db.Create(b).Error; err != nil { + t.Fatalf("create binding: %v", err) + } + + r := gin.New() + r.POST("/admin/bindings/batch", h.BatchBindings) + + payload := map[string]any{ + "action": "status", + "status": "inactive", + "ids": []uint{b.ID}, + } + bb, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/admin/bindings/batch", bytes.NewReader(bb)) + req.Header.Set("Content-Type", "application/json") + 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 updated model.Binding + if err := db.First(&updated, b.ID).Error; err != nil { + t.Fatalf("reload binding: %v", err) + } + if updated.Status != "inactive" { + t.Fatalf("expected status inactive, got %q", updated.Status) + } +} diff --git a/internal/api/provider_admin_handler_test.go b/internal/api/provider_admin_handler_test.go index a1a58cf..8827cd4 100644 --- a/internal/api/provider_admin_handler_test.go +++ b/internal/api/provider_admin_handler_test.go @@ -1,10 +1,12 @@ package api import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" + "time" "github.com/ez-api/ez-api/internal/model" "github.com/gin-gonic/gin" @@ -108,3 +110,53 @@ func TestAdmin_FetchProviderModels_OpenAICompatible(t *testing.T) { t.Fatalf("expected models to update, got %q", updated.Models) } } + +func TestAdmin_BatchProviders_Status(t *testing.T) { + h, db := newTestHandler(t) + + banUntil := time.Now().Add(2 * time.Hour).UTC() + p := &model.Provider{ + Name: "p1", + Type: "openai", + BaseURL: "https://api.openai.com/v1", + Group: "default", + Models: "gpt-4o-mini", + Status: "manual_disabled", + BanReason: "bad", + BanUntil: &banUntil, + } + if err := db.Create(p).Error; err != nil { + t.Fatalf("create provider: %v", err) + } + + r := gin.New() + r.POST("/admin/providers/batch", h.BatchProviders) + + payload := map[string]any{ + "action": "status", + "status": "active", + "ids": []uint{p.ID}, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/admin/providers/batch", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + 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 updated model.Provider + if err := db.First(&updated, p.ID).Error; err != nil { + t.Fatalf("reload provider: %v", err) + } + if updated.Status != "active" { + t.Fatalf("expected status active, got %q", updated.Status) + } + if updated.BanReason != "" { + t.Fatalf("expected ban_reason cleared, got %q", updated.BanReason) + } + if updated.BanUntil != nil { + t.Fatalf("expected ban_until cleared, got %v", updated.BanUntil) + } +}