From 564a9619edb5cb46fe7b9e128dc7eeeae68d281f Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 20 May 2026 13:44:06 +0800 Subject: [PATCH] feat: add channel monitor admin API Add admin endpoints to manage monitors, run probes, and inspect history and rollups. --- controller/channel_monitor.go | 372 ++++++++++++++++++++++++++++++++++ router/api-router.go | 12 ++ 2 files changed, 384 insertions(+) create mode 100644 controller/channel_monitor.go diff --git a/controller/channel_monitor.go b/controller/channel_monitor.go new file mode 100644 index 00000000..6b0c8f18 --- /dev/null +++ b/controller/channel_monitor.go @@ -0,0 +1,372 @@ +package controller + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type createChannelMonitorRequest struct { + Name string `json:"name"` + ChannelId int `json:"channel_id"` + TargetUrl string `json:"target_url"` + PrimaryModel string `json:"primary_model"` + ExtraModels string `json:"extra_models"` + CheckInterval int `json:"check_interval"` + Timeout int `json:"timeout"` + RequestTemplates string `json:"request_templates"` + CustomHeaders string `json:"custom_headers"` + CustomBody string `json:"custom_body"` + Status *int `json:"status"` +} + +type updateChannelMonitorRequest struct { + Name *string `json:"name"` + ChannelId *int `json:"channel_id"` + TargetUrl *string `json:"target_url"` + PrimaryModel *string `json:"primary_model"` + ExtraModels *string `json:"extra_models"` + CheckInterval *int `json:"check_interval"` + Timeout *int `json:"timeout"` + RequestTemplates *string `json:"request_templates"` + CustomHeaders *string `json:"custom_headers"` + CustomBody *string `json:"custom_body"` + Status *int `json:"status"` +} + +func GetChannelMonitors(c *gin.Context) { + pageInfo := getChannelMonitorPageQuery(c) + monitors, total, err := model.GetChannelMonitors(pageInfo.GetPage(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(monitors) + common.ApiSuccess(c, pageInfo) +} + +func CreateChannelMonitor(c *gin.Context) { + var req createChannelMonitorRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + + monitor := &model.ChannelMonitor{ + Name: strings.TrimSpace(req.Name), + ChannelId: req.ChannelId, + TargetUrl: strings.TrimSpace(req.TargetUrl), + PrimaryModel: strings.TrimSpace(req.PrimaryModel), + ExtraModels: strings.TrimSpace(req.ExtraModels), + CheckInterval: req.CheckInterval, + Timeout: req.Timeout, + RequestTemplates: strings.TrimSpace(req.RequestTemplates), + CustomHeaders: strings.TrimSpace(req.CustomHeaders), + CustomBody: req.CustomBody, + Status: 1, + } + if req.Status != nil { + monitor.Status = *req.Status + } + if monitor.CheckInterval == 0 { + monitor.CheckInterval = 300 + } + if monitor.Timeout == 0 { + monitor.Timeout = 30 + } + + if err := validateChannelMonitor(monitor); err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + if err := monitor.Insert(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, monitor) +} + +func GetChannelMonitor(c *gin.Context) { + monitor, ok := getChannelMonitorFromParam(c) + if !ok { + return + } + common.ApiSuccess(c, monitor) +} + +func UpdateChannelMonitor(c *gin.Context) { + monitor, ok := getChannelMonitorFromParam(c) + if !ok { + return + } + + var req updateChannelMonitorRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + + if req.Name != nil { + monitor.Name = strings.TrimSpace(*req.Name) + } + if req.ChannelId != nil { + monitor.ChannelId = *req.ChannelId + } + if req.TargetUrl != nil { + monitor.TargetUrl = strings.TrimSpace(*req.TargetUrl) + } + if req.PrimaryModel != nil { + monitor.PrimaryModel = strings.TrimSpace(*req.PrimaryModel) + } + if req.ExtraModels != nil { + monitor.ExtraModels = strings.TrimSpace(*req.ExtraModels) + } + if req.CheckInterval != nil { + monitor.CheckInterval = *req.CheckInterval + } + if req.Timeout != nil { + monitor.Timeout = *req.Timeout + } + if req.RequestTemplates != nil { + monitor.RequestTemplates = strings.TrimSpace(*req.RequestTemplates) + } + if req.CustomHeaders != nil { + monitor.CustomHeaders = strings.TrimSpace(*req.CustomHeaders) + } + if req.CustomBody != nil { + monitor.CustomBody = *req.CustomBody + } + if req.Status != nil { + monitor.Status = *req.Status + } + + if err := validateChannelMonitor(monitor); err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + if err := monitor.Update(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, monitor) +} + +func DeleteChannelMonitor(c *gin.Context) { + monitor, ok := getChannelMonitorFromParam(c) + if !ok { + return + } + monitor.Status = 0 + if err := monitor.Update(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} + +func RunChannelMonitorCheck(c *gin.Context) { + monitor, ok := getChannelMonitorFromParam(c) + if !ok { + return + } + if err := service.RunMonitorCheck(monitor); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"message": "检查已完成"}) +} + +func GetChannelMonitorHistory(c *gin.Context) { + monitor, ok := getChannelMonitorFromParam(c) + if !ok { + return + } + + pageInfo := getChannelMonitorPageQuery(c) + startTime, ok := parseChannelMonitorOptionalTime(c, "start_time") + if !ok { + return + } + endTime, ok := parseChannelMonitorOptionalTime(c, "end_time") + if !ok { + return + } + + histories, total, err := model.GetMonitorHistory( + monitor.Id, + pageInfo.GetPage(), + pageInfo.GetPageSize(), + strings.TrimSpace(c.Query("model_name")), + strings.TrimSpace(c.Query("status")), + startTime, + endTime, + ) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(histories) + common.ApiSuccess(c, pageInfo) +} + +func GetChannelMonitorRollup(c *gin.Context) { + monitor, ok := getChannelMonitorFromParam(c) + if !ok { + return + } + + startDate := strings.TrimSpace(c.Query("start_date")) + endDate := strings.TrimSpace(c.Query("end_date")) + if err := validateChannelMonitorDateRange(startDate, endDate); err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + + rollups, err := model.GetMonitorDailyRollups( + monitor.Id, + strings.TrimSpace(c.Query("model_name")), + startDate, + endDate, + ) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, rollups) +} + +func getChannelMonitorFromParam(c *gin.Context) (*model.ChannelMonitor, bool) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return nil, false + } + monitor, err := model.GetChannelMonitorByID(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiErrorMsg(c, "渠道监控不存在") + return nil, false + } + common.ApiError(c, err) + return nil, false + } + return monitor, true +} + +func getChannelMonitorPageQuery(c *gin.Context) *common.PageInfo { + pageInfo := common.GetPageQuery(c) + if c.Query("p") == "" { + if page, err := strconv.Atoi(c.Query("page")); err == nil && page > 0 { + pageInfo.Page = page + } + } + return pageInfo +} + +func validateChannelMonitor(monitor *model.ChannelMonitor) error { + if strings.TrimSpace(monitor.Name) == "" { + return errors.New("name 不能为空") + } + if strings.TrimSpace(monitor.TargetUrl) == "" { + return errors.New("target_url 不能为空") + } + if strings.TrimSpace(monitor.PrimaryModel) == "" { + return errors.New("primary_model 不能为空") + } + if monitor.CheckInterval < 15 || monitor.CheckInterval > 3600 { + return errors.New("check_interval 必须在 15 到 3600 之间") + } + if monitor.Timeout < 1 || monitor.Timeout > 300 { + return errors.New("timeout 必须在 1 到 300 之间") + } + if monitor.Status != 0 && monitor.Status != 1 { + return errors.New("status 必须是 0 或 1") + } + if err := service.ValidateChannelMonitorTargetURL(monitor.TargetUrl); err != nil { + return fmt.Errorf("target_url 不合法: %w", err) + } + if err := validateChannelMonitorJSONStringArray(monitor.ExtraModels, "extra_models"); err != nil { + return err + } + if err := validateChannelMonitorJSONStringArray(monitor.RequestTemplates, "request_templates"); err != nil { + return err + } + if err := validateChannelMonitorJSONHeaders(monitor.CustomHeaders); err != nil { + return err + } + return nil +} + +func validateChannelMonitorJSONStringArray(raw string, field string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + var values []string + if err := common.Unmarshal([]byte(raw), &values); err != nil { + return fmt.Errorf("%s 必须是 JSON 字符串数组", field) + } + for _, value := range values { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s 不能包含空字符串", field) + } + } + return nil +} + +func validateChannelMonitorJSONHeaders(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + var headers map[string]string + if err := common.Unmarshal([]byte(raw), &headers); err != nil { + return errors.New("custom_headers 必须是 JSON 字符串对象") + } + for key := range headers { + if strings.TrimSpace(key) == "" { + return errors.New("custom_headers 不能包含空 header 名") + } + } + return nil +} + +func parseChannelMonitorOptionalTime(c *gin.Context, key string) (*time.Time, bool) { + value := strings.TrimSpace(c.Query(key)) + if value == "" { + return nil, true + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + common.ApiErrorMsg(c, key+" 必须是 RFC3339 格式") + return nil, false + } + return &parsed, true +} + +func validateChannelMonitorDateRange(startDate string, endDate string) error { + if startDate != "" { + if _, err := time.Parse("2006-01-02", startDate); err != nil { + return errors.New("start_date 必须是 YYYY-MM-DD 格式") + } + } + if endDate != "" { + if _, err := time.Parse("2006-01-02", endDate); err != nil { + return errors.New("end_date 必须是 YYYY-MM-DD 格式") + } + } + return nil +} diff --git a/router/api-router.go b/router/api-router.go index f16c8106..0d923b5d 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -315,6 +315,18 @@ func SetApiRouter(router *gin.Engine) { announcementAdminRoute.PUT("/:id", controller.UpdateAnnouncement) announcementAdminRoute.DELETE("/:id", controller.DeleteAnnouncement) } + channelMonitorAdminRoute := apiRouter.Group("/admin/channel-monitors") + channelMonitorAdminRoute.Use(middleware.AdminAuth()) + { + channelMonitorAdminRoute.GET("", controller.GetChannelMonitors) + channelMonitorAdminRoute.POST("", controller.CreateChannelMonitor) + channelMonitorAdminRoute.GET("/:id", controller.GetChannelMonitor) + channelMonitorAdminRoute.PUT("/:id", controller.UpdateChannelMonitor) + channelMonitorAdminRoute.DELETE("/:id", controller.DeleteChannelMonitor) + channelMonitorAdminRoute.POST("/:id/run", controller.RunChannelMonitorCheck) + channelMonitorAdminRoute.GET("/:id/history", controller.GetChannelMonitorHistory) + channelMonitorAdminRoute.GET("/:id/rollup", controller.GetChannelMonitorRollup) + } logRoute := apiRouter.Group("/log") logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs) logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs)