mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
feat(api): add namespaces, batch ops, and admin logs
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -367,49 +367,14 @@ func (h *AdminHandler) DeleteMaster(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var m model.Master
|
||||
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
|
||||
}
|
||||
|
||||
54
internal/api/admin_master_ops.go
Normal file
54
internal/api/admin_master_ops.go
Normal file
@@ -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)
|
||||
}
|
||||
198
internal/api/batch_handler.go
Normal file
198
internal/api/batch_handler.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
219
internal/api/namespace_handler.go
Normal file
219
internal/api/namespace_handler.go
Normal file
@@ -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"})
|
||||
}
|
||||
86
internal/api/namespace_handler_test.go
Normal file
86
internal/api/namespace_handler_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
69
internal/api/operation_log_handler.go
Normal file
69
internal/api/operation_log_handler.go
Normal file
@@ -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)
|
||||
}
|
||||
52
internal/middleware/operation_log.go
Normal file
52
internal/middleware/operation_log.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
46
internal/middleware/operation_log_test.go
Normal file
46
internal/middleware/operation_log_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
11
internal/model/namespace.go
Normal file
11
internal/model/namespace.go
Normal file
@@ -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"`
|
||||
}
|
||||
18
internal/model/operation_log.go
Normal file
18
internal/model/operation_log.go
Normal file
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user