From 369c204c1636883a897c09a196440bb671cdabbc Mon Sep 17 00:00:00 2001 From: zenfun Date: Sun, 21 Dec 2025 00:53:52 +0800 Subject: [PATCH] feat: add admin log deletion --- cmd/server/main.go | 1 + internal/api/log_handler.go | 56 ++++++++++++++++++++ internal/api/log_handler_test.go | 88 ++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) diff --git a/cmd/server/main.go b/cmd/server/main.go index 28cba31..f456bc7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -212,6 +212,7 @@ func main() { adminGroup.GET("/models", handler.ListModels) adminGroup.PUT("/models/:id", handler.UpdateModel) adminGroup.GET("/logs", handler.ListLogs) + adminGroup.DELETE("/logs", handler.DeleteLogs) adminGroup.GET("/logs/stats", handler.LogStats) adminGroup.GET("/stats", adminHandler.GetAdminStats) adminGroup.POST("/bindings", handler.CreateBinding) diff --git a/internal/api/log_handler.go b/internal/api/log_handler.go index 9b989e3..2f9e307 100644 --- a/internal/api/log_handler.go +++ b/internal/api/log_handler.go @@ -161,6 +161,62 @@ type LogStatsResponse struct { ByStatus map[string]int64 `json:"by_status"` } +type DeleteLogsRequest struct { + Before string `json:"before"` + KeyID uint `json:"key_id"` + Model string `json:"model"` +} + +type DeleteLogsResponse struct { + DeletedCount int64 `json:"deleted_count"` +} + +// DeleteLogs godoc +// @Summary Delete logs (admin) +// @Description Delete logs before a given timestamp with optional filters +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param request body DeleteLogsRequest true "Delete filters" +// @Success 200 {object} DeleteLogsResponse +// @Failure 400 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/logs [delete] +func (h *Handler) DeleteLogs(c *gin.Context) { + var req DeleteLogsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + before := strings.TrimSpace(req.Before) + if before == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "before is required"}) + return + } + ts, err := time.Parse(time.RFC3339, before) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid before time"}) + return + } + + q := h.db.Unscoped().Where("created_at < ?", ts.UTC()) + if req.KeyID > 0 { + q = q.Where("key_id = ?", req.KeyID) + } + if model := strings.TrimSpace(req.Model); model != "" { + q = q.Where("model_name = ?", model) + } + + res := q.Delete(&model.LogRecord{}) + if res.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete logs", "details": res.Error.Error()}) + return + } + c.JSON(http.StatusOK, DeleteLogsResponse{DeletedCount: res.RowsAffected}) +} + // LogStats godoc // @Summary Log stats (admin) // @Description Aggregate log stats with basic filtering diff --git a/internal/api/log_handler_test.go b/internal/api/log_handler_test.go index 900b26b..d2b1fbd 100644 --- a/internal/api/log_handler_test.go +++ b/internal/api/log_handler_test.go @@ -1,11 +1,13 @@ package api import ( + "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" + "time" "github.com/ez-api/ez-api/internal/model" "github.com/gin-gonic/gin" @@ -78,3 +80,89 @@ func TestMaster_ListSelfLogs_FiltersByMaster(t *testing.T) { t.Fatalf("expected key_id %d, got %+v", k1.ID, resp.Items[0]) } } + +func TestAdmin_DeleteLogs_BeforeFilters(t *testing.T) { + 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.LogRecord{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + now := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + older := now.Add(-48 * time.Hour) + cutoff := now.Add(-24 * time.Hour) + + log1 := model.LogRecord{KeyID: 1, ModelName: "m1", StatusCode: 200} + log1.CreatedAt = older + log2 := model.LogRecord{KeyID: 2, ModelName: "m1", StatusCode: 200} + log2.CreatedAt = older + log3 := model.LogRecord{KeyID: 1, ModelName: "m1", StatusCode: 200} + log3.CreatedAt = now + + if err := db.Create(&log1).Error; err != nil { + t.Fatalf("create log1: %v", err) + } + if err := db.Create(&log2).Error; err != nil { + t.Fatalf("create log2: %v", err) + } + if err := db.Create(&log3).Error; err != nil { + t.Fatalf("create log3: %v", err) + } + + h := &Handler{db: db} + r := gin.New() + r.DELETE("/admin/logs", h.DeleteLogs) + + body := []byte(fmt.Sprintf(`{"before":"%s","key_id":1,"model":"m1"}`, cutoff.Format(time.RFC3339))) + req := httptest.NewRequest(http.MethodDelete, "/admin/logs", bytes.NewReader(body)) + 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 resp DeleteLogsResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.DeletedCount != 1 { + t.Fatalf("expected deleted_count=1, got %d", resp.DeletedCount) + } + + var remaining int64 + if err := db.Model(&model.LogRecord{}).Count(&remaining).Error; err != nil { + t.Fatalf("count logs: %v", err) + } + if remaining != 2 { + t.Fatalf("expected 2 logs remaining, got %d", remaining) + } +} + +func TestAdmin_DeleteLogs_RequiresBefore(t *testing.T) { + 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.LogRecord{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + h := &Handler{db: db} + r := gin.New() + r.DELETE("/admin/logs", h.DeleteLogs) + + req := httptest.NewRequest(http.MethodDelete, "/admin/logs", bytes.NewReader([]byte(`{}`))) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", rr.Code, rr.Body.String()) + } +}