feat(api): add namespaces, batch ops, and admin logs

This commit is contained in:
zenfun
2025-12-21 23:16:27 +08:00
parent 73147fc55a
commit c2ed2f3f9e
12 changed files with 824 additions and 42 deletions

View File

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

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

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

View File

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

View 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"})
}

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

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

View 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
}
}

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

View 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"`
}

View 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"`
}