From d9d79638c0a0ea0dcec25f36ccb5acf6ec58f078 Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 20 May 2026 13:06:58 +0800 Subject: [PATCH] feat: add announcement admin API Add authenticated admin endpoints to create, edit, list, and archive announcements with safe HTML rendering. --- controller/announcement.go | 272 +++++++++++++++++++++++++++++++++++++ router/api-router.go | 9 ++ 2 files changed, 281 insertions(+) create mode 100644 controller/announcement.go diff --git a/controller/announcement.go b/controller/announcement.go new file mode 100644 index 00000000..be1abd26 --- /dev/null +++ b/controller/announcement.go @@ -0,0 +1,272 @@ +package controller + +import ( + "errors" + "html" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const ( + announcementStatusDraft = "draft" + announcementStatusActive = "active" + announcementStatusArchived = "archived" + + announcementNotifyModeSilent = "silent" + announcementNotifyModePopup = "popup" +) + +type createAnnouncementRequest struct { + Title string `json:"title"` + Content string `json:"content"` + NotifyMode string `json:"notify_mode"` + Status string `json:"status"` + JsonRules string `json:"json_rules"` + StartsAt string `json:"starts_at"` + EndsAt string `json:"ends_at"` +} + +type updateAnnouncementRequest struct { + Title *string `json:"title"` + Content *string `json:"content"` + Status *string `json:"status"` + NotifyMode *string `json:"notify_mode"` + JsonRules *string `json:"json_rules"` + StartsAt *string `json:"starts_at"` + EndsAt *string `json:"ends_at"` +} + +func GetAnnouncements(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + status := strings.TrimSpace(c.Query("status")) + if status != "" && !isValidAnnouncementStatus(status) { + common.ApiErrorMsg(c, "status 必须是 draft、active 或 archived") + return + } + + announcements, total, err := model.GetAnnouncements(pageInfo.GetPage(), pageInfo.GetPageSize(), status) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(announcements) + common.ApiSuccess(c, pageInfo) +} + +func CreateAnnouncement(c *gin.Context) { + var req createAnnouncementRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + + title := strings.TrimSpace(req.Title) + if title == "" { + common.ApiErrorMsg(c, "title 不能为空") + return + } + if strings.TrimSpace(req.Content) == "" { + common.ApiErrorMsg(c, "content 不能为空") + return + } + + status := strings.TrimSpace(req.Status) + if status == "" { + status = announcementStatusDraft + } + if !isValidAnnouncementStatus(status) { + common.ApiErrorMsg(c, "status 必须是 draft、active 或 archived") + return + } + + notifyMode := strings.TrimSpace(req.NotifyMode) + if notifyMode == "" { + notifyMode = announcementNotifyModeSilent + } + if !isValidAnnouncementNotifyMode(notifyMode) { + common.ApiErrorMsg(c, "notify_mode 必须是 silent 或 popup") + return + } + + startsAt, err := parseAnnouncementTime(req.StartsAt) + if err != nil { + common.ApiErrorMsg(c, "starts_at 必须是 RFC3339 格式") + return + } + endsAt, err := parseAnnouncementTime(req.EndsAt) + if err != nil { + common.ApiErrorMsg(c, "ends_at 必须是 RFC3339 格式") + return + } + + announcement := &model.Announcement{ + Title: title, + Content: req.Content, + ContentHtml: renderAnnouncementContentHTML(req.Content), + Status: status, + NotifyMode: notifyMode, + JsonRules: req.JsonRules, + StartsAt: startsAt, + EndsAt: endsAt, + CreatedBy: c.GetInt("id"), + } + + if err := announcement.Insert(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, announcement) +} + +func GetAnnouncement(c *gin.Context) { + announcement, ok := getAnnouncementFromParam(c) + if !ok { + return + } + common.ApiSuccess(c, announcement) +} + +func UpdateAnnouncement(c *gin.Context) { + announcement, ok := getAnnouncementFromParam(c) + if !ok { + return + } + + var req updateAnnouncementRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + + if req.Title != nil { + title := strings.TrimSpace(*req.Title) + if title == "" { + common.ApiErrorMsg(c, "title 不能为空") + return + } + announcement.Title = title + } + if req.Content != nil { + if strings.TrimSpace(*req.Content) == "" { + common.ApiErrorMsg(c, "content 不能为空") + return + } + announcement.Content = *req.Content + announcement.ContentHtml = renderAnnouncementContentHTML(*req.Content) + } + if req.Status != nil { + status := strings.TrimSpace(*req.Status) + if !isValidAnnouncementStatus(status) { + common.ApiErrorMsg(c, "status 必须是 draft、active 或 archived") + return + } + announcement.Status = status + } + if req.NotifyMode != nil { + notifyMode := strings.TrimSpace(*req.NotifyMode) + if !isValidAnnouncementNotifyMode(notifyMode) { + common.ApiErrorMsg(c, "notify_mode 必须是 silent 或 popup") + return + } + announcement.NotifyMode = notifyMode + } + if req.JsonRules != nil { + announcement.JsonRules = *req.JsonRules + } + if req.StartsAt != nil { + startsAt, err := parseAnnouncementTime(*req.StartsAt) + if err != nil { + common.ApiErrorMsg(c, "starts_at 必须是 RFC3339 格式") + return + } + announcement.StartsAt = startsAt + } + if req.EndsAt != nil { + endsAt, err := parseAnnouncementTime(*req.EndsAt) + if err != nil { + common.ApiErrorMsg(c, "ends_at 必须是 RFC3339 格式") + return + } + announcement.EndsAt = endsAt + } + + if err := announcement.Update(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, announcement) +} + +func DeleteAnnouncement(c *gin.Context) { + announcement, ok := getAnnouncementFromParam(c) + if !ok { + return + } + announcement.Status = announcementStatusArchived + if err := announcement.Update(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} + +func getAnnouncementFromParam(c *gin.Context) (*model.Announcement, bool) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return nil, false + } + announcement, err := model.GetAnnouncementByID(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiErrorMsg(c, "公告不存在") + return nil, false + } + common.ApiError(c, err) + return nil, false + } + return announcement, true +} + +func parseAnnouncementTime(value string) (*time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil, err + } + return &parsed, nil +} + +func renderAnnouncementContentHTML(content string) string { + escaped := html.EscapeString(content) + return strings.ReplaceAll(escaped, "\n", "
\n") +} + +func isValidAnnouncementStatus(status string) bool { + switch status { + case announcementStatusDraft, announcementStatusActive, announcementStatusArchived: + return true + default: + return false + } +} + +func isValidAnnouncementNotifyMode(notifyMode string) bool { + switch notifyMode { + case announcementNotifyModeSilent, announcementNotifyModePopup: + return true + default: + return false + } +} diff --git a/router/api-router.go b/router/api-router.go index e32f7a90..30109b10 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -304,6 +304,15 @@ func SetApiRouter(router *gin.Engine) { promoCodeRoute.DELETE("/:id", controller.DeletePromoCode) promoCodeRoute.GET("/:id/usages", controller.GetPromoCodeUsages) } + announcementAdminRoute := apiRouter.Group("/admin/announcements") + announcementAdminRoute.Use(middleware.AdminAuth()) + { + announcementAdminRoute.GET("", controller.GetAnnouncements) + announcementAdminRoute.POST("", controller.CreateAnnouncement) + announcementAdminRoute.GET("/:id", controller.GetAnnouncement) + announcementAdminRoute.PUT("/:id", controller.UpdateAnnouncement) + announcementAdminRoute.DELETE("/:id", controller.DeleteAnnouncement) + } logRoute := apiRouter.Group("/log") logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs) logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs)