From c2ed2f3f9e89d252cd364735c24c6a9b067d14bc Mon Sep 17 00:00:00 2001 From: zenfun Date: Sun, 21 Dec 2025 23:16:27 +0800 Subject: [PATCH] feat(api): add namespaces, batch ops, and admin logs --- cmd/server/main.go | 15 +- internal/api/admin_handler.go | 45 +---- internal/api/admin_master_ops.go | 54 ++++++ internal/api/batch_handler.go | 198 +++++++++++++++++++ internal/api/model_handler_test.go | 53 ++++++ internal/api/namespace_handler.go | 219 ++++++++++++++++++++++ internal/api/namespace_handler_test.go | 86 +++++++++ internal/api/operation_log_handler.go | 69 +++++++ internal/middleware/operation_log.go | 52 +++++ internal/middleware/operation_log_test.go | 46 +++++ internal/model/namespace.go | 11 ++ internal/model/operation_log.go | 18 ++ 12 files changed, 824 insertions(+), 42 deletions(-) create mode 100644 internal/api/admin_master_ops.go create mode 100644 internal/api/batch_handler.go create mode 100644 internal/api/namespace_handler.go create mode 100644 internal/api/namespace_handler_test.go create mode 100644 internal/api/operation_log_handler.go create mode 100644 internal/middleware/operation_log.go create mode 100644 internal/middleware/operation_log_test.go create mode 100644 internal/model/namespace.go create mode 100644 internal/model/operation_log.go diff --git a/cmd/server/main.go b/cmd/server/main.go index de440d1..6af8a39 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -127,7 +127,7 @@ func main() { // Auto Migrate if logDB != db { - if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.Provider{}, &model.Model{}, &model.Binding{}); err != nil { + if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.Provider{}, &model.Model{}, &model.Binding{}, &model.Namespace{}, &model.OperationLog{}); err != nil { fatal(logger, "failed to auto migrate", "err", err) } if err := logDB.AutoMigrate(&model.LogRecord{}); err != nil { @@ -137,7 +137,7 @@ func main() { fatal(logger, "failed to ensure log indexes", "err", err) } } else { - if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.Provider{}, &model.Model{}, &model.Binding{}, &model.LogRecord{}); err != nil { + if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.Provider{}, &model.Model{}, &model.Binding{}, &model.Namespace{}, &model.OperationLog{}, &model.LogRecord{}); err != nil { fatal(logger, "failed to auto migrate", "err", err) } if err := service.EnsureLogIndexes(db); err != nil { @@ -250,18 +250,26 @@ func main() { // Admin Routes adminGroup := r.Group("/admin") adminGroup.Use(middleware.AdminAuthMiddleware(adminService)) + adminGroup.Use(middleware.OperationLogMiddleware(db)) { adminGroup.POST("/masters", adminHandler.CreateMaster) adminGroup.GET("/masters", adminHandler.ListMasters) adminGroup.GET("/masters/:id", adminHandler.GetMaster) adminGroup.PUT("/masters/:id", adminHandler.UpdateMaster) adminGroup.DELETE("/masters/:id", adminHandler.DeleteMaster) + adminGroup.POST("/masters/batch", adminHandler.BatchMasters) adminGroup.POST("/masters/:id/manage", adminHandler.ManageMaster) adminGroup.POST("/masters/:id/keys", adminHandler.IssueChildKeyForMaster) adminGroup.GET("/masters/:id/access", handler.GetMasterAccess) adminGroup.PUT("/masters/:id/access", handler.UpdateMasterAccess) adminGroup.GET("/keys/:id/access", handler.GetKeyAccess) adminGroup.PUT("/keys/:id/access", handler.UpdateKeyAccess) + adminGroup.GET("/operation-logs", adminHandler.ListOperationLogs) + adminGroup.POST("/namespaces", handler.CreateNamespace) + adminGroup.GET("/namespaces", handler.ListNamespaces) + adminGroup.GET("/namespaces/:id", handler.GetNamespace) + adminGroup.PUT("/namespaces/:id", handler.UpdateNamespace) + adminGroup.DELETE("/namespaces/:id", handler.DeleteNamespace) adminGroup.GET("/features", featureHandler.ListFeatures) adminGroup.PUT("/features", featureHandler.UpdateFeatures) adminGroup.GET("/model-registry/status", modelRegistryHandler.GetStatus) @@ -277,12 +285,14 @@ func main() { adminGroup.POST("/providers/google", handler.CreateProviderGoogle) adminGroup.PUT("/providers/:id", handler.UpdateProvider) adminGroup.DELETE("/providers/:id", handler.DeleteProvider) + adminGroup.POST("/providers/batch", handler.BatchProviders) adminGroup.POST("/providers/:id/test", handler.TestProvider) adminGroup.POST("/providers/:id/fetch-models", handler.FetchProviderModels) adminGroup.POST("/models", handler.CreateModel) adminGroup.GET("/models", handler.ListModels) adminGroup.PUT("/models/:id", handler.UpdateModel) adminGroup.DELETE("/models/:id", handler.DeleteModel) + adminGroup.POST("/models/batch", handler.BatchModels) adminGroup.GET("/logs", handler.ListLogs) adminGroup.DELETE("/logs", handler.DeleteLogs) adminGroup.GET("/logs/stats", handler.LogStats) @@ -294,6 +304,7 @@ func main() { adminGroup.GET("/bindings/:id", handler.GetBinding) adminGroup.PUT("/bindings/:id", handler.UpdateBinding) adminGroup.DELETE("/bindings/:id", handler.DeleteBinding) + adminGroup.POST("/bindings/batch", handler.BatchBindings) adminGroup.POST("/sync/snapshot", handler.SyncSnapshot) } diff --git a/internal/api/admin_handler.go b/internal/api/admin_handler.go index 158a61a..493146a 100644 --- a/internal/api/admin_handler.go +++ b/internal/api/admin_handler.go @@ -367,48 +367,13 @@ func (h *AdminHandler) DeleteMaster(c *gin.Context) { return } - var m model.Master - if err := h.db.First(&m, uint(idU64)).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "master not found"}) - return - } - - nextEpoch := m.Epoch + 1 - if nextEpoch <= 0 { - nextEpoch = 1 - } - if err := h.db.Model(&m).Updates(map[string]any{ - "status": "suspended", - "epoch": nextEpoch, - }).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke master", "details": err.Error()}) - return - } - if err := h.db.First(&m, uint(idU64)).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload master", "details": err.Error()}) - return - } - - // Revoke all child keys too (defense-in-depth; master status already blocks in DP). - if err := h.db.Model(&model.Key{}).Where("master_id = ?", m.ID).Update("status", "suspended").Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke child keys", "details": err.Error()}) - return - } - - if err := h.syncService.SyncMaster(&m); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync master", "details": err.Error()}) - return - } - var keys []model.Key - if err := h.db.Where("master_id = ?", m.ID).Find(&keys).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload keys for sync", "details": err.Error()}) - return - } - for i := range keys { - if err := h.syncService.SyncKey(&keys[i]); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync key", "details": err.Error()}) + if err := h.revokeMasterByID(uint(idU64)); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "master not found"}) return } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke master", "details": err.Error()}) + return } c.JSON(http.StatusOK, gin.H{"status": "revoked"}) diff --git a/internal/api/admin_master_ops.go b/internal/api/admin_master_ops.go new file mode 100644 index 0000000..6bb8807 --- /dev/null +++ b/internal/api/admin_master_ops.go @@ -0,0 +1,54 @@ +package api + +import ( + "errors" + + "github.com/ez-api/ez-api/internal/model" + "gorm.io/gorm" +) + +func (h *AdminHandler) revokeMasterByID(id uint) error { + if id == 0 { + return gorm.ErrRecordNotFound + } + var m model.Master + if err := h.db.First(&m, id).Error; err != nil { + return err + } + + nextEpoch := m.Epoch + 1 + if nextEpoch <= 0 { + nextEpoch = 1 + } + if err := h.db.Model(&m).Updates(map[string]any{ + "status": "suspended", + "epoch": nextEpoch, + }).Error; err != nil { + return err + } + if err := h.db.First(&m, id).Error; err != nil { + return err + } + + if err := h.db.Model(&model.Key{}).Where("master_id = ?", m.ID).Update("status", "suspended").Error; err != nil { + return err + } + + if err := h.syncService.SyncMaster(&m); err != nil { + return err + } + var keys []model.Key + if err := h.db.Where("master_id = ?", m.ID).Find(&keys).Error; err != nil { + return err + } + for i := range keys { + if err := h.syncService.SyncKey(&keys[i]); 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 new file mode 100644 index 0000000..e1f8de9 --- /dev/null +++ b/internal/api/batch_handler.go @@ -0,0 +1,198 @@ +package api + +import ( + "errors" + "net/http" + "strings" + + "github.com/ez-api/ez-api/internal/model" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type BatchActionRequest struct { + Action string `json:"action"` + IDs []uint `json:"ids"` +} + +type BatchResult struct { + ID uint `json:"id"` + Error string `json:"error,omitempty"` +} + +type BatchResponse struct { + Action string `json:"action"` + Success []uint `json:"success"` + Failed []BatchResult `json:"failed"` +} + +func normalizeBatchAction(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + if raw == "" { + return "delete" + } + return raw +} + +func (h *AdminHandler) BatchMasters(c *gin.Context) { + var req BatchActionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + action := normalizeBatchAction(req.Action) + if action != "delete" { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported action"}) + return + } + if len(req.IDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ids required"}) + return + } + + resp := BatchResponse{Action: action} + for _, id := range req.IDs { + if err := h.revokeMasterByID(id); err != nil { + if isRecordNotFound(err) { + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: "not found"}) + continue + } + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) + continue + } + resp.Success = append(resp.Success, id) + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handler) BatchProviders(c *gin.Context) { + var req BatchActionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + action := normalizeBatchAction(req.Action) + if action != "delete" { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported action"}) + return + } + if len(req.IDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ids required"}) + return + } + + resp := BatchResponse{Action: action} + needsBindingSync := false + for _, id := range req.IDs { + var p model.Provider + if err := h.db.First(&p, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: "not found"}) + continue + } + 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 + } + 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 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync bindings", "details": err.Error()}) + return + } + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handler) BatchModels(c *gin.Context) { + var req BatchActionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + action := normalizeBatchAction(req.Action) + if action != "delete" { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported action"}) + return + } + if len(req.IDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ids required"}) + return + } + + resp := BatchResponse{Action: action} + for _, id := range req.IDs { + var m model.Model + if err := h.db.First(&m, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: "not found"}) + continue + } + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) + continue + } + if err := h.db.Delete(&m).Error; err != nil { + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) + continue + } + if err := h.sync.SyncModelDelete(&m); err != nil { + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: err.Error()}) + continue + } + resp.Success = append(resp.Success, id) + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handler) BatchBindings(c *gin.Context) { + var req BatchActionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + action := normalizeBatchAction(req.Action) + if action != "delete" { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported action"}) + return + } + if len(req.IDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ids required"}) + return + } + + resp := BatchResponse{Action: action} + needsSync := false + for _, id := range req.IDs { + var b model.Binding + if err := h.db.First(&b, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + resp.Failed = append(resp.Failed, BatchResult{ID: id, Error: "not found"}) + continue + } + 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 + } + resp.Success = append(resp.Success, id) + needsSync = true + } + if needsSync { + if err := h.sync.SyncBindings(h.db); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync bindings", "details": err.Error()}) + return + } + } + c.JSON(http.StatusOK, resp) +} diff --git a/internal/api/model_handler_test.go b/internal/api/model_handler_test.go index cc8c308..00a1ab9 100644 --- a/internal/api/model_handler_test.go +++ b/internal/api/model_handler_test.go @@ -147,3 +147,56 @@ func TestDeleteModel_RemovesMeta(t *testing.T) { t.Fatalf("expected model deleted, got count=%d", remaining) } } + +func TestBatchModels_Delete(t *testing.T) { + h, db, mr := newTestHandlerWithRedis(t) + + r := gin.New() + r.POST("/admin/models", h.CreateModel) + r.POST("/admin/models/batch", h.BatchModels) + + create := func(name string) uint { + reqBody := map[string]any{"name": name} + b, _ := json.Marshal(reqBody) + req := httptest.NewRequest(http.MethodPost, "/admin/models", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d body=%s", rr.Code, rr.Body.String()) + } + var created model.Model + if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return created.ID + } + + id1 := create("ns.b1") + id2 := create("ns.b2") + + payload := map[string]any{ + "action": "delete", + "ids": []uint{id1, id2}, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/admin/models/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 remaining int64 + if err := db.Model(&model.Model{}).Count(&remaining).Error; err != nil { + t.Fatalf("count: %v", err) + } + if remaining != 0 { + t.Fatalf("expected models deleted, got %d", remaining) + } + if got := mr.HGet("meta:models", "ns.b1"); got != "" { + t.Fatalf("expected meta removed, got %q", got) + } +} diff --git a/internal/api/namespace_handler.go b/internal/api/namespace_handler.go new file mode 100644 index 0000000..2412e6a --- /dev/null +++ b/internal/api/namespace_handler.go @@ -0,0 +1,219 @@ +package api + +import ( + "net/http" + "strings" + + "github.com/ez-api/ez-api/internal/model" + "github.com/gin-gonic/gin" +) + +type NamespaceRequest struct { + Name string `json:"name"` + Status string `json:"status"` + Description string `json:"description"` +} + +// CreateNamespace godoc +// @Summary Create namespace +// @Description Create a namespace for bindings +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param namespace body NamespaceRequest true "Namespace payload" +// @Success 201 {object} model.Namespace +// @Failure 400 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/namespaces [post] +func (h *Handler) CreateNamespace(c *gin.Context) { + var req NamespaceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + name := strings.TrimSpace(req.Name) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name required"}) + return + } + status := strings.TrimSpace(req.Status) + if status == "" { + status = "active" + } + + ns := model.Namespace{ + Name: name, + Status: status, + Description: strings.TrimSpace(req.Description), + } + if err := h.db.Create(&ns).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create namespace", "details": err.Error()}) + return + } + c.JSON(http.StatusCreated, ns) +} + +// ListNamespaces godoc +// @Summary List namespaces +// @Description List all namespaces +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param page query int false "page (1-based)" +// @Param limit query int false "limit (default 50, max 200)" +// @Param search query string false "search by name/description" +// @Success 200 {array} model.Namespace +// @Failure 500 {object} gin.H +// @Router /admin/namespaces [get] +func (h *Handler) ListNamespaces(c *gin.Context) { + var out []model.Namespace + q := h.db.Model(&model.Namespace{}).Order("id desc") + query := parseListQuery(c) + q = applyListSearch(q, query.Search, "name", "description") + q = applyListPagination(q, query) + if err := q.Find(&out).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list namespaces", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, out) +} + +// GetNamespace godoc +// @Summary Get namespace +// @Description Get a namespace by id +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Namespace ID" +// @Success 200 {object} model.Namespace +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/namespaces/{id} [get] +func (h *Handler) GetNamespace(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + var ns model.Namespace + if err := h.db.First(&ns, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "namespace not found"}) + return + } + c.JSON(http.StatusOK, ns) +} + +type UpdateNamespaceRequest struct { + Name *string `json:"name,omitempty"` + Status *string `json:"status,omitempty"` + Description *string `json:"description,omitempty"` +} + +// UpdateNamespace godoc +// @Summary Update namespace +// @Description Update a namespace +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param id path int true "Namespace ID" +// @Param namespace body UpdateNamespaceRequest true "Update payload" +// @Success 200 {object} model.Namespace +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/namespaces/{id} [put] +func (h *Handler) UpdateNamespace(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + + var req UpdateNamespaceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var ns model.Namespace + if err := h.db.First(&ns, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "namespace not found"}) + return + } + + update := map[string]any{} + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name required"}) + return + } + update["name"] = name + } + if req.Status != nil { + status := strings.TrimSpace(*req.Status) + if status == "" { + status = "active" + } + update["status"] = status + } + if req.Description != nil { + update["description"] = strings.TrimSpace(*req.Description) + } + if len(update) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + if err := h.db.Model(&ns).Updates(update).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update namespace", "details": err.Error()}) + return + } + if err := h.db.First(&ns, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload namespace", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, ns) +} + +// DeleteNamespace godoc +// @Summary Delete namespace +// @Description Delete a namespace and its bindings +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Namespace ID" +// @Success 200 {object} gin.H +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/namespaces/{id} [delete] +func (h *Handler) DeleteNamespace(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + var ns model.Namespace + if err := h.db.First(&ns, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "namespace not found"}) + return + } + + if err := h.db.Delete(&ns).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete namespace", "details": err.Error()}) + return + } + + if err := h.db.Where("namespace = ?", ns.Name).Delete(&model.Binding{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete namespace bindings", "details": err.Error()}) + return + } + if err := h.sync.SyncBindings(h.db); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync bindings", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) +} diff --git a/internal/api/namespace_handler_test.go b/internal/api/namespace_handler_test.go new file mode 100644 index 0000000..36bc911 --- /dev/null +++ b/internal/api/namespace_handler_test.go @@ -0,0 +1,86 @@ +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 newTestHandlerWithNamespace(t *testing.T) (*Handler, *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.Provider{}, &model.Binding{}, &model.Namespace{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + sync := service.NewSyncService(rdb) + return NewHandler(db, db, sync, nil, rdb, nil), db, mr +} + +func TestNamespaceCRUD_DeleteCleansBindings(t *testing.T) { + h, db, _ := newTestHandlerWithNamespace(t) + + if err := db.Create(&model.Binding{ + Namespace: "ns1", + PublicModel: "m1", + RouteGroup: "default", + SelectorType: "exact", + SelectorValue: "m1", + Status: "active", + }).Error; err != nil { + t.Fatalf("create binding: %v", err) + } + + r := gin.New() + r.POST("/admin/namespaces", h.CreateNamespace) + r.DELETE("/admin/namespaces/:id", h.DeleteNamespace) + + body := []byte(`{"name":"ns1","description":"demo"}`) + req := httptest.NewRequest(http.MethodPost, "/admin/namespaces", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d body=%s", rr.Code, rr.Body.String()) + } + var created model.Namespace + if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + delReq := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/admin/namespaces/%d", created.ID), nil) + delRec := httptest.NewRecorder() + r.ServeHTTP(delRec, delReq) + + if delRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", delRec.Code, delRec.Body.String()) + } + + var remaining int64 + if err := db.Model(&model.Binding{}).Where("namespace = ?", "ns1").Count(&remaining).Error; err != nil { + t.Fatalf("count bindings: %v", err) + } + if remaining != 0 { + t.Fatalf("expected bindings deleted, got %d", remaining) + } +} diff --git a/internal/api/operation_log_handler.go b/internal/api/operation_log_handler.go new file mode 100644 index 0000000..09b0d85 --- /dev/null +++ b/internal/api/operation_log_handler.go @@ -0,0 +1,69 @@ +package api + +import ( + "net/http" + + "github.com/ez-api/ez-api/internal/model" + "github.com/gin-gonic/gin" +) + +type OperationLogView struct { + ID uint `json:"id"` + CreatedAt int64 `json:"created_at"` + Actor string `json:"actor"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query"` + StatusCode int `json:"status_code"` + LatencyMs int64 `json:"latency_ms"` + ClientIP string `json:"client_ip"` + RequestID string `json:"request_id"` + UserAgent string `json:"user_agent"` + ErrorMessage string `json:"error_message"` +} + +func toOperationLogView(l model.OperationLog) OperationLogView { + return OperationLogView{ + ID: l.ID, + CreatedAt: l.CreatedAt.UTC().Unix(), + Actor: l.Actor, + Method: l.Method, + Path: l.Path, + Query: l.Query, + StatusCode: l.StatusCode, + LatencyMs: l.LatencyMs, + ClientIP: l.ClientIP, + RequestID: l.RequestID, + UserAgent: l.UserAgent, + ErrorMessage: l.ErrorMessage, + } +} + +// ListOperationLogs godoc +// @Summary List operation logs +// @Description List admin operation logs +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param page query int false "page (1-based)" +// @Param limit query int false "limit (default 50, max 200)" +// @Param search query string false "search by actor/method/path" +// @Success 200 {array} OperationLogView +// @Failure 500 {object} gin.H +// @Router /admin/operation-logs [get] +func (h *AdminHandler) ListOperationLogs(c *gin.Context) { + var rows []model.OperationLog + q := h.db.Model(&model.OperationLog{}).Order("id desc") + query := parseListQuery(c) + q = applyListSearch(q, query.Search, "actor", "method", "path") + q = applyListPagination(q, query) + if err := q.Find(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list operation logs", "details": err.Error()}) + return + } + out := make([]OperationLogView, 0, len(rows)) + for _, row := range rows { + out = append(out, toOperationLogView(row)) + } + c.JSON(http.StatusOK, out) +} diff --git a/internal/middleware/operation_log.go b/internal/middleware/operation_log.go new file mode 100644 index 0000000..1406415 --- /dev/null +++ b/internal/middleware/operation_log.go @@ -0,0 +1,52 @@ +package middleware + +import ( + "net/http" + "strings" + "time" + + "github.com/ez-api/ez-api/internal/model" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// OperationLogMiddleware records admin mutating operations. +func OperationLogMiddleware(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + + if db == nil { + return + } + switch c.Request.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return + } + + errMsg := "" + if len(c.Errors) > 0 { + errMsg = c.Errors.String() + } + reqID := "" + if v, ok := c.Get("request_id"); ok { + if s, ok := v.(string); ok { + reqID = s + } + } + + log := model.OperationLog{ + Actor: "admin", + Method: c.Request.Method, + Path: c.Request.URL.Path, + Query: strings.TrimSpace(c.Request.URL.RawQuery), + StatusCode: c.Writer.Status(), + LatencyMs: time.Since(start).Milliseconds(), + ClientIP: c.ClientIP(), + RequestID: reqID, + UserAgent: c.GetHeader("User-Agent"), + ErrorMessage: errMsg, + } + _ = db.Create(&log).Error + } +} diff --git a/internal/middleware/operation_log_test.go b/internal/middleware/operation_log_test.go new file mode 100644 index 0000000..c04d1a0 --- /dev/null +++ b/internal/middleware/operation_log_test.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/ez-api/ez-api/internal/model" + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestOperationLogMiddleware_WritesLog(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open("file:oplog?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.OperationLog{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + r := gin.New() + r.Use(OperationLogMiddleware(db)) + r.POST("/admin/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req := httptest.NewRequest(http.MethodPost, "/admin/test?x=1", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + var count int64 + if err := db.Model(&model.OperationLog{}).Count(&count).Error; err != nil { + t.Fatalf("count: %v", err) + } + if count != 1 { + t.Fatalf("expected 1 log, got %d", count) + } +} diff --git a/internal/model/namespace.go b/internal/model/namespace.go new file mode 100644 index 0000000..817a152 --- /dev/null +++ b/internal/model/namespace.go @@ -0,0 +1,11 @@ +package model + +import "gorm.io/gorm" + +// Namespace represents a logical namespace for public models/bindings. +type Namespace struct { + gorm.Model + Name string `gorm:"size:100;uniqueIndex;not null" json:"name"` + Status string `gorm:"size:50;default:'active'" json:"status"` + Description string `gorm:"size:255" json:"description"` +} diff --git a/internal/model/operation_log.go b/internal/model/operation_log.go new file mode 100644 index 0000000..d82938b --- /dev/null +++ b/internal/model/operation_log.go @@ -0,0 +1,18 @@ +package model + +import "gorm.io/gorm" + +// OperationLog stores admin actions for audit. +type OperationLog struct { + gorm.Model + Actor string `gorm:"size:100" json:"actor"` + Method string `gorm:"size:10" json:"method"` + Path string `gorm:"size:255" json:"path"` + Query string `gorm:"size:2048" json:"query"` + StatusCode int `json:"status_code"` + LatencyMs int64 `json:"latency_ms"` + ClientIP string `gorm:"size:64" json:"client_ip"` + RequestID string `gorm:"size:64" json:"request_id"` + UserAgent string `gorm:"size:255" json:"user_agent"` + ErrorMessage string `gorm:"size:1024" json:"error_message"` +}