From 2b5e657b3d049bfc9459f60289949364846b59ff Mon Sep 17 00:00:00 2001 From: zenfun Date: Wed, 31 Dec 2025 13:43:48 +0800 Subject: [PATCH] feat(api): add alert system with CRUD endpoints and statistics Introduce a comprehensive alert management system for monitoring system events and notifications. Changes include: - Add Alert model with type, severity, status, and metadata fields - Implement AlertHandler with full CRUD operations (create, list, get, acknowledge, resolve, dismiss) - Add alert statistics endpoint for counts by status and severity - Register Alert model in database auto-migration - Add minute-level aggregation to log stats (limited to 6-hour range) --- cmd/server/main.go | 12 +- internal/api/alert_handler.go | 405 ++++++++++++++++++++++++++++++++++ internal/api/log_handler.go | 69 +++++- internal/model/alert.go | 56 +++++ 4 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 internal/api/alert_handler.go create mode 100644 internal/model/alert.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 5c1878a..926aade 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -143,7 +143,7 @@ func main() { // Auto Migrate if logDB != db { - if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.ProviderGroup{}, &model.APIKey{}, &model.Model{}, &model.Binding{}, &model.Namespace{}, &model.OperationLog{}, &model.SyncOutbox{}); err != nil { + if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.ProviderGroup{}, &model.APIKey{}, &model.Model{}, &model.Binding{}, &model.Namespace{}, &model.OperationLog{}, &model.SyncOutbox{}, &model.Alert{}); err != nil { fatal(logger, "failed to auto migrate", "err", err) } if err := logDB.AutoMigrate(&model.LogRecord{}); err != nil { @@ -153,7 +153,7 @@ func main() { fatal(logger, "failed to ensure log indexes", "err", err) } } else { - if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.ProviderGroup{}, &model.APIKey{}, &model.Model{}, &model.Binding{}, &model.Namespace{}, &model.OperationLog{}, &model.LogRecord{}, &model.SyncOutbox{}); err != nil { + if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.ProviderGroup{}, &model.APIKey{}, &model.Model{}, &model.Binding{}, &model.Namespace{}, &model.OperationLog{}, &model.LogRecord{}, &model.SyncOutbox{}, &model.Alert{}); err != nil { fatal(logger, "failed to auto migrate", "err", err) } if err := service.EnsureLogIndexes(db); err != nil { @@ -220,6 +220,7 @@ func main() { adminHandler := api.NewAdminHandler(db, logDB, masterService, syncService, statsService, logPartitioner) masterHandler := api.NewMasterHandler(db, logDB, masterService, syncService, statsService, logPartitioner) dashboardHandler := api.NewDashboardHandler(db, logDB, statsService, logPartitioner) + alertHandler := api.NewAlertHandler(db) internalHandler := api.NewInternalHandler(db) featureHandler := api.NewFeatureHandler(rdb) authHandler := api.NewAuthHandler(db, rdb, adminService, masterService) @@ -358,6 +359,13 @@ func main() { adminGroup.GET("/realtime", adminHandler.GetAdminRealtime) adminGroup.GET("/dashboard/summary", dashboardHandler.GetSummary) adminGroup.GET("/apikey-stats/summary", adminHandler.GetAPIKeyStatsSummary) + adminGroup.GET("/alerts", alertHandler.ListAlerts) + adminGroup.POST("/alerts", alertHandler.CreateAlert) + adminGroup.GET("/alerts/stats", alertHandler.GetAlertStats) + adminGroup.GET("/alerts/:id", alertHandler.GetAlert) + adminGroup.POST("/alerts/:id/ack", alertHandler.AcknowledgeAlert) + adminGroup.POST("/alerts/:id/resolve", alertHandler.ResolveAlert) + adminGroup.DELETE("/alerts/:id", alertHandler.DismissAlert) adminGroup.POST("/bindings", handler.CreateBinding) adminGroup.GET("/bindings", handler.ListBindings) adminGroup.GET("/bindings/:id", handler.GetBinding) diff --git a/internal/api/alert_handler.go b/internal/api/alert_handler.go new file mode 100644 index 0000000..6fd13ba --- /dev/null +++ b/internal/api/alert_handler.go @@ -0,0 +1,405 @@ +package api + +import ( + "net/http" + "strings" + "time" + + "github.com/ez-api/ez-api/internal/model" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// AlertHandler handles alert-related API endpoints +type AlertHandler struct { + db *gorm.DB +} + +// NewAlertHandler creates a new AlertHandler +func NewAlertHandler(db *gorm.DB) *AlertHandler { + return &AlertHandler{db: db} +} + +// AlertView represents an alert in API responses +type AlertView struct { + ID uint `json:"id"` + Type string `json:"type"` + Severity string `json:"severity"` + Status string `json:"status"` + Title string `json:"title"` + Message string `json:"message"` + RelatedID uint `json:"related_id,omitempty"` + RelatedType string `json:"related_type,omitempty"` + RelatedName string `json:"related_name,omitempty"` + Metadata string `json:"metadata,omitempty"` + AckedAt *int64 `json:"acked_at,omitempty"` + AckedBy string `json:"acked_by,omitempty"` + ResolvedAt *int64 `json:"resolved_at,omitempty"` + ExpiresAt *int64 `json:"expires_at,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func toAlertView(a model.Alert) AlertView { + view := AlertView{ + ID: a.ID, + Type: string(a.Type), + Severity: string(a.Severity), + Status: string(a.Status), + Title: a.Title, + Message: a.Message, + RelatedID: a.RelatedID, + RelatedType: a.RelatedType, + RelatedName: a.RelatedName, + Metadata: a.Metadata, + AckedBy: a.AckedBy, + CreatedAt: a.CreatedAt.UTC().Unix(), + UpdatedAt: a.UpdatedAt.UTC().Unix(), + } + if a.AckedAt != nil { + ts := a.AckedAt.UTC().Unix() + view.AckedAt = &ts + } + if a.ResolvedAt != nil { + ts := a.ResolvedAt.UTC().Unix() + view.ResolvedAt = &ts + } + if a.ExpiresAt != nil { + ts := a.ExpiresAt.UTC().Unix() + view.ExpiresAt = &ts + } + return view +} + +// ListAlertsResponse is the response for listing alerts +type ListAlertsResponse struct { + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Items []AlertView `json:"items"` +} + +// ListAlerts godoc +// @Summary List alerts +// @Description List system alerts with optional filters +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param limit query int false "limit (default 50, max 200)" +// @Param offset query int false "offset" +// @Param status query string false "filter by status (active, acknowledged, resolved, dismissed)" +// @Param severity query string false "filter by severity (info, warning, critical)" +// @Param type query string false "filter by type (rate_limit, error_spike, quota_exceeded, key_disabled, key_expired, provider_down)" +// @Success 200 {object} ListAlertsResponse +// @Failure 500 {object} gin.H +// @Router /admin/alerts [get] +func (h *AlertHandler) ListAlerts(c *gin.Context) { + limit, offset := parseLimitOffset(c) + + q := h.db.Model(&model.Alert{}).Order("id desc") + + if status := strings.TrimSpace(c.Query("status")); status != "" { + q = q.Where("status = ?", status) + } + if severity := strings.TrimSpace(c.Query("severity")); severity != "" { + q = q.Where("severity = ?", severity) + } + if alertType := strings.TrimSpace(c.Query("type")); alertType != "" { + q = q.Where("type = ?", alertType) + } + + var total int64 + if err := q.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count alerts", "details": err.Error()}) + return + } + + var alerts []model.Alert + if err := q.Limit(limit).Offset(offset).Find(&alerts).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list alerts", "details": err.Error()}) + return + } + + items := make([]AlertView, 0, len(alerts)) + for _, a := range alerts { + items = append(items, toAlertView(a)) + } + + c.JSON(http.StatusOK, ListAlertsResponse{ + Total: total, + Limit: limit, + Offset: offset, + Items: items, + }) +} + +// GetAlert godoc +// @Summary Get alert +// @Description Get a single alert by ID +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Alert ID" +// @Success 200 {object} AlertView +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Router /admin/alerts/{id} [get] +func (h *AlertHandler) GetAlert(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + + var alert model.Alert + if err := h.db.First(&alert, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "alert not found"}) + return + } + + c.JSON(http.StatusOK, toAlertView(alert)) +} + +// CreateAlertRequest is the request body for creating an alert +type CreateAlertRequest struct { + Type string `json:"type" binding:"required"` + Severity string `json:"severity" binding:"required"` + Title string `json:"title" binding:"required"` + Message string `json:"message"` + RelatedID uint `json:"related_id"` + RelatedType string `json:"related_type"` + RelatedName string `json:"related_name"` + Metadata string `json:"metadata"` + ExpiresAt *int64 `json:"expires_at"` +} + +// CreateAlert godoc +// @Summary Create alert +// @Description Create a new system alert +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param request body CreateAlertRequest true "Alert data" +// @Success 201 {object} AlertView +// @Failure 400 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/alerts [post] +func (h *AlertHandler) CreateAlert(c *gin.Context) { + var req CreateAlertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate type + validTypes := map[string]bool{ + "rate_limit": true, "error_spike": true, "quota_exceeded": true, + "key_disabled": true, "key_expired": true, "provider_down": true, + } + if !validTypes[req.Type] { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert type"}) + return + } + + // Validate severity + validSeverities := map[string]bool{"info": true, "warning": true, "critical": true} + if !validSeverities[req.Severity] { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid severity"}) + return + } + + alert := model.Alert{ + Type: model.AlertType(req.Type), + Severity: model.AlertSeverity(req.Severity), + Status: model.AlertStatusActive, + Title: strings.TrimSpace(req.Title), + Message: strings.TrimSpace(req.Message), + RelatedID: req.RelatedID, + RelatedType: strings.TrimSpace(req.RelatedType), + RelatedName: strings.TrimSpace(req.RelatedName), + Metadata: req.Metadata, + } + + if req.ExpiresAt != nil && *req.ExpiresAt > 0 { + t := time.Unix(*req.ExpiresAt, 0).UTC() + alert.ExpiresAt = &t + } + + if err := h.db.Create(&alert).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create alert", "details": err.Error()}) + return + } + + c.JSON(http.StatusCreated, toAlertView(alert)) +} + +// AckAlertRequest is the request body for acknowledging an alert +type AckAlertRequest struct { + AckedBy string `json:"acked_by"` +} + +// AcknowledgeAlert godoc +// @Summary Acknowledge alert +// @Description Mark an alert as acknowledged +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param id path int true "Alert ID" +// @Param request body AckAlertRequest false "Ack data" +// @Success 200 {object} AlertView +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/alerts/{id}/ack [post] +func (h *AlertHandler) AcknowledgeAlert(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + + var alert model.Alert + if err := h.db.First(&alert, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "alert not found"}) + return + } + + var req AckAlertRequest + _ = c.ShouldBindJSON(&req) + + now := time.Now().UTC() + update := map[string]any{ + "status": model.AlertStatusAcknowledged, + "acked_at": now, + "acked_by": strings.TrimSpace(req.AckedBy), + } + + if err := h.db.Model(&alert).Updates(update).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to acknowledge alert", "details": err.Error()}) + return + } + + if err := h.db.First(&alert, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload alert", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, toAlertView(alert)) +} + +// ResolveAlert godoc +// @Summary Resolve alert +// @Description Mark an alert as resolved +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Alert ID" +// @Success 200 {object} AlertView +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/alerts/{id}/resolve [post] +func (h *AlertHandler) ResolveAlert(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + + var alert model.Alert + if err := h.db.First(&alert, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "alert not found"}) + return + } + + now := time.Now().UTC() + update := map[string]any{ + "status": model.AlertStatusResolved, + "resolved_at": now, + } + + if err := h.db.Model(&alert).Updates(update).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve alert", "details": err.Error()}) + return + } + + if err := h.db.First(&alert, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload alert", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, toAlertView(alert)) +} + +// DismissAlert godoc +// @Summary Dismiss alert +// @Description Dismiss an alert (soft delete) +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Alert ID" +// @Success 200 {object} gin.H +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/alerts/{id} [delete] +func (h *AlertHandler) DismissAlert(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + + var alert model.Alert + if err := h.db.First(&alert, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "alert not found"}) + return + } + + if err := h.db.Model(&alert).Update("status", model.AlertStatusDismissed).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to dismiss alert", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "dismissed"}) +} + +// AlertStats represents alert statistics +type AlertStats struct { + Total int64 `json:"total"` + Active int64 `json:"active"` + Acknowledged int64 `json:"acknowledged"` + Resolved int64 `json:"resolved"` + Critical int64 `json:"critical"` + Warning int64 `json:"warning"` + Info int64 `json:"info"` +} + +// GetAlertStats godoc +// @Summary Alert statistics +// @Description Get alert count statistics by status and severity +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Success 200 {object} AlertStats +// @Failure 500 {object} gin.H +// @Router /admin/alerts/stats [get] +func (h *AlertHandler) GetAlertStats(c *gin.Context) { + var total, active, acknowledged, resolved, critical, warning, info int64 + + h.db.Model(&model.Alert{}).Count(&total) + h.db.Model(&model.Alert{}).Where("status = ?", "active").Count(&active) + h.db.Model(&model.Alert{}).Where("status = ?", "acknowledged").Count(&acknowledged) + h.db.Model(&model.Alert{}).Where("status = ?", "resolved").Count(&resolved) + h.db.Model(&model.Alert{}).Where("severity = ? AND status = ?", "critical", "active").Count(&critical) + h.db.Model(&model.Alert{}).Where("severity = ? AND status = ?", "warning", "active").Count(&warning) + h.db.Model(&model.Alert{}).Where("severity = ? AND status = ?", "info", "active").Count(&info) + + c.JSON(http.StatusOK, AlertStats{ + Total: total, + Active: active, + Acknowledged: acknowledged, + Resolved: resolved, + Critical: critical, + Warning: warning, + Info: info, + }) +} diff --git a/internal/api/log_handler.go b/internal/api/log_handler.go index d84a72b..eed31aa 100644 --- a/internal/api/log_handler.go +++ b/internal/api/log_handler.go @@ -235,6 +235,8 @@ type GroupedStatsItem struct { Month string `json:"month,omitempty"` // For group_by=hour Hour string `json:"hour,omitempty"` + // For group_by=minute + Minute string `json:"minute,omitempty"` Count int64 `json:"count"` TokensIn int64 `json:"tokens_in"` @@ -348,24 +350,29 @@ func (h *Handler) deleteLogsBefore(cutoff time.Time, keyID uint, modelName strin // LogStats godoc // @Summary Log stats (admin) -// @Description Aggregate log stats with basic filtering. Use group_by param for grouped statistics (model/day/month/hour). Without group_by returns LogStatsResponse; with group_by returns GroupedStatsResponse. +// @Description Aggregate log stats with basic filtering. Use group_by param for grouped statistics (model/day/month/hour/minute). Without group_by returns LogStatsResponse; with group_by returns GroupedStatsResponse. Note: minute-level aggregation is limited to 6-hour time ranges. // @Tags admin // @Produce json // @Security AdminAuth // @Param since query int false "unix seconds" // @Param until query int false "unix seconds" -// @Param group_by query string false "group by dimension: model, day, month, hour. Returns GroupedStatsResponse when specified." Enums(model, day, month, hour) +// @Param group_by query string false "group by dimension: model, day, month, hour, minute. Returns GroupedStatsResponse when specified." Enums(model, day, month, hour, minute) // @Success 200 {object} LogStatsResponse "Default aggregated stats (when group_by is not specified)" // @Success 200 {object} GroupedStatsResponse "Grouped stats (when group_by is specified)" +// @Failure 400 {object} gin.H // @Failure 500 {object} gin.H // @Router /admin/logs/stats [get] func (h *Handler) LogStats(c *gin.Context) { q := h.logBaseQuery() + + var sinceTime, untilTime *time.Time if t, ok := parseUnixSeconds(c.Query("since")); ok { q = q.Where("created_at >= ?", t) + sinceTime = &t } if t, ok := parseUnixSeconds(c.Query("until")); ok { q = q.Where("created_at <= ?", t) + untilTime = &t } groupBy := strings.TrimSpace(c.Query("group_by")) @@ -382,6 +389,9 @@ func (h *Handler) LogStats(c *gin.Context) { case "hour": h.logStatsByHour(c, q) return + case "minute": + h.logStatsByMinute(c, q, sinceTime, untilTime) + return } // Default: aggregated stats (backward compatible) @@ -544,6 +554,61 @@ func (h *Handler) logStatsByHour(c *gin.Context, q *gorm.DB) { c.JSON(http.StatusOK, GroupedStatsResponse{Items: items}) } +// maxMinuteRangeDuration is the maximum time range allowed for minute-level aggregation (6 hours) +const maxMinuteRangeDuration = 6 * time.Hour + +// logStatsByMinute handles group_by=minute with time range validation +func (h *Handler) logStatsByMinute(c *gin.Context, q *gorm.DB, sinceTime, untilTime *time.Time) { + // Validate time range - minute-level aggregation requires since/until and max 6 hours + if sinceTime == nil || untilTime == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "minute-level aggregation requires both 'since' and 'until' parameters"}) + return + } + + duration := untilTime.Sub(*sinceTime) + if duration > maxMinuteRangeDuration { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "time range too large for minute-level aggregation", + "max_hours": 6, + "actual_hours": duration.Hours(), + }) + return + } + + if duration < 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "'until' must be after 'since'"}) + return + } + + type minuteStats struct { + Minute string + Cnt int64 + TokensIn int64 + TokensOut int64 + AvgLatencyMs float64 + } + var rows []minuteStats + // PostgreSQL DATE_TRUNC for minute-level aggregation + if err := q.Select(`DATE_TRUNC('minute', created_at) as minute, COUNT(*) as cnt, COALESCE(SUM(tokens_in),0) as tokens_in, COALESCE(SUM(tokens_out),0) as tokens_out, COALESCE(AVG(latency_ms),0) as avg_latency_ms`). + Group("minute"). + Order("minute ASC"). + Scan(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate by minute", "details": err.Error()}) + return + } + items := make([]GroupedStatsItem, 0, len(rows)) + for _, r := range rows { + items = append(items, GroupedStatsItem{ + Minute: r.Minute, + Count: r.Cnt, + TokensIn: r.TokensIn, + TokensOut: r.TokensOut, + AvgLatencyMs: r.AvgLatencyMs, + }) + } + c.JSON(http.StatusOK, GroupedStatsResponse{Items: items}) +} + // ListSelfLogs godoc // @Summary List logs (master) // @Description List request logs for the authenticated master diff --git a/internal/model/alert.go b/internal/model/alert.go new file mode 100644 index 0000000..c53e27d --- /dev/null +++ b/internal/model/alert.go @@ -0,0 +1,56 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// AlertType defines the type of alert +type AlertType string + +const ( + AlertTypeRateLimit AlertType = "rate_limit" + AlertTypeErrorSpike AlertType = "error_spike" + AlertTypeQuotaExceeded AlertType = "quota_exceeded" + AlertTypeKeyDisabled AlertType = "key_disabled" + AlertTypeKeyExpired AlertType = "key_expired" + AlertTypeProviderDown AlertType = "provider_down" +) + +// AlertSeverity defines the severity level of an alert +type AlertSeverity string + +const ( + AlertSeverityInfo AlertSeverity = "info" + AlertSeverityWarning AlertSeverity = "warning" + AlertSeverityCritical AlertSeverity = "critical" +) + +// AlertStatus defines the status of an alert +type AlertStatus string + +const ( + AlertStatusActive AlertStatus = "active" + AlertStatusAcknowledged AlertStatus = "acknowledged" + AlertStatusResolved AlertStatus = "resolved" + AlertStatusDismissed AlertStatus = "dismissed" +) + +// Alert represents a system alert or notification +type Alert struct { + gorm.Model + Type AlertType `gorm:"size:50;not null;index" json:"type"` + Severity AlertSeverity `gorm:"size:20;not null;index" json:"severity"` + Status AlertStatus `gorm:"size:20;not null;default:'active';index" json:"status"` + Title string `gorm:"size:255;not null" json:"title"` + Message string `gorm:"type:text" json:"message"` + RelatedID uint `gorm:"index" json:"related_id,omitempty"` + RelatedType string `gorm:"size:50" json:"related_type,omitempty"` // master, key, apikey, provider_group + RelatedName string `gorm:"size:255" json:"related_name,omitempty"` + Metadata string `gorm:"type:text" json:"metadata,omitempty"` // JSON encoded additional data + AckedAt *time.Time `json:"acked_at,omitempty"` + AckedBy string `gorm:"size:100" json:"acked_by,omitempty"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` + ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty"` // Auto-dismiss after this time +}