From eec9f4dcaef28849fcde28c1a7c815ef2d6c3e88 Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 20 May 2026 13:15:35 +0800 Subject: [PATCH] feat: add user announcement endpoints Expose active announcements to authenticated users, track read state, and feed active database announcements into /api/status with legacy fallback. --- controller/announcement_user.go | 117 ++++++++++++++++++++++++++++++++ controller/misc.go | 78 +++++++++++++++++---- router/api-router.go | 2 + 3 files changed, 185 insertions(+), 12 deletions(-) create mode 100644 controller/announcement_user.go diff --git a/controller/announcement_user.go b/controller/announcement_user.go new file mode 100644 index 00000000..9f7a1efb --- /dev/null +++ b/controller/announcement_user.go @@ -0,0 +1,117 @@ +package controller + +import ( + "errors" + "strconv" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type userAnnouncementResponse struct { + model.Announcement + IsRead bool `json:"is_read"` +} + +func GetUserAnnouncements(c *gin.Context) { + userId := c.GetInt("id") + unreadOnly := c.Query("unread") == "true" + + var announcements []model.Announcement + var err error + if unreadOnly { + announcements, err = model.GetUnreadAnnouncements(userId) + } else { + announcements, err = model.GetActiveAnnouncements() + } + if err != nil { + common.ApiError(c, err) + return + } + + result := make([]userAnnouncementResponse, 0, len(announcements)) + for _, announcement := range announcements { + isRead := false + if !unreadOnly { + isRead, err = model.IsAnnouncementRead(announcement.Id, userId) + if err != nil { + common.ApiError(c, err) + return + } + } + result = append(result, userAnnouncementResponse{ + Announcement: announcement, + IsRead: isRead, + }) + } + + common.ApiSuccess(c, result) +} + +func MarkAnnouncementRead(c *gin.Context) { + announcementId, err := strconv.Atoi(c.Param("id")) + if err != nil || announcementId <= 0 { + common.ApiErrorMsg(c, "无效的公告 ID") + return + } + + announcement, err := model.GetAnnouncementByID(announcementId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiErrorMsg(c, "公告不存在或不可见") + return + } + common.ApiError(c, err) + return + } + if !isAnnouncementVisible(announcement) { + common.ApiErrorMsg(c, "公告不存在或不可见") + return + } + + userId := c.GetInt("id") + isRead, err := model.IsAnnouncementRead(announcementId, userId) + if err != nil { + common.ApiError(c, err) + return + } + if isRead { + common.ApiSuccess(c, nil) + return + } + + read := &model.AnnouncementRead{ + AnnouncementId: announcementId, + UserId: userId, + ReadAt: time.Now(), + } + if err := read.Insert(); err != nil { + isRead, checkErr := model.IsAnnouncementRead(announcementId, userId) + if checkErr == nil && isRead { + common.ApiSuccess(c, nil) + return + } + common.ApiError(c, err) + return + } + + common.ApiSuccess(c, nil) +} + +func isAnnouncementVisible(announcement *model.Announcement) bool { + if announcement.Status != announcementStatusActive { + return false + } + now := time.Now() + if announcement.StartsAt != nil && announcement.StartsAt.After(now) { + return false + } + if announcement.EndsAt != nil && announcement.EndsAt.Before(now) { + return false + } + return true +} diff --git a/controller/misc.go b/controller/misc.go index 29b3a5c5..bad75558 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" @@ -43,10 +44,23 @@ func GetStatus(c *gin.Context) { cs := console_setting.GetConsoleSetting() common.OptionMapRWMutex.RLock() - defer common.OptionMapRWMutex.RUnlock() passkeySetting := system_setting.GetPasskeySettings() legalSetting := system_setting.GetLegalSettings() + apiInfoEnabled := cs.ApiInfoEnabled + announcementsEnabled := cs.AnnouncementsEnabled + faqEnabled := cs.FAQEnabled + var apiInfo []map[string]interface{} + if apiInfoEnabled { + apiInfo = console_setting.GetApiInfo() + } + legacyAnnouncements := console_setting.GetAnnouncements() + var faq []map[string]interface{} + if faqEnabled { + faq = console_setting.GetFAQ() + } + headerNavModules := common.OptionMap["HeaderNavModules"] + sidebarModulesAdmin := common.OptionMap["SidebarModulesAdmin"] data := gin.H{ "version": common.Version, @@ -94,14 +108,14 @@ func GetStatus(c *gin.Context) { "stripe_unit_price": setting.StripeUnitPrice, // 面板启用开关 - "api_info_enabled": cs.ApiInfoEnabled, + "api_info_enabled": apiInfoEnabled, "uptime_kuma_enabled": cs.UptimeKumaEnabled, - "announcements_enabled": cs.AnnouncementsEnabled, - "faq_enabled": cs.FAQEnabled, + "announcements_enabled": announcementsEnabled, + "faq_enabled": faqEnabled, // 模块管理配置 - "HeaderNavModules": common.OptionMap["HeaderNavModules"], - "SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"], + "HeaderNavModules": headerNavModules, + "SidebarModulesAdmin": sidebarModulesAdmin, "oidc_enabled": system_setting.GetOIDCSettings().Enabled, "oidc_client_id": system_setting.GetOIDCSettings().ClientId, @@ -118,16 +132,17 @@ func GetStatus(c *gin.Context) { "privacy_policy_enabled": legalSetting.PrivacyPolicy != "", "checkin_enabled": operation_setting.GetCheckinSetting().Enabled, } + common.OptionMapRWMutex.RUnlock() // 根据启用状态注入可选内容 - if cs.ApiInfoEnabled { - data["api_info"] = console_setting.GetApiInfo() + if apiInfoEnabled { + data["api_info"] = apiInfo } - if cs.AnnouncementsEnabled { - data["announcements"] = console_setting.GetAnnouncements() + if announcementsEnabled { + data["announcements"] = getStatusAnnouncements(legacyAnnouncements) } - if cs.FAQEnabled { - data["faq"] = console_setting.GetFAQ() + if faqEnabled { + data["faq"] = faq } // Add enabled custom OAuth providers @@ -166,6 +181,45 @@ func GetStatus(c *gin.Context) { return } +func getStatusAnnouncements(fallback []map[string]interface{}) []map[string]interface{} { + announcements, err := model.GetActiveAnnouncements() + if err == nil && len(announcements) > 0 { + result := make([]map[string]interface{}, 0, len(announcements)) + for _, announcement := range announcements { + result = append(result, buildStatusAnnouncement(announcement)) + } + return result + } + return fallback +} + +func buildStatusAnnouncement(announcement model.Announcement) map[string]interface{} { + publishDate := announcement.CreatedAt + if announcement.StartsAt != nil { + publishDate = *announcement.StartsAt + } + if publishDate.IsZero() { + publishDate = time.Now() + } + + return map[string]interface{}{ + "id": announcement.Id, + "title": announcement.Title, + "content": announcement.Content, + "content_html": announcement.ContentHtml, + "type": "default", + "extra": announcement.Title, + "publishDate": publishDate.Format(time.RFC3339), + "status": announcement.Status, + "notify_mode": announcement.NotifyMode, + "json_rules": announcement.JsonRules, + "starts_at": announcement.StartsAt, + "ends_at": announcement.EndsAt, + "created_at": announcement.CreatedAt, + "updated_at": announcement.UpdatedAt, + } +} + func GetNotice(c *gin.Context) { common.OptionMapRWMutex.RLock() defer common.OptionMapRWMutex.RUnlock() diff --git a/router/api-router.go b/router/api-router.go index 30109b10..f16c8106 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -22,6 +22,8 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/setup", controller.PostSetup) apiRouter.GET("/status", controller.GetStatus) apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus) + apiRouter.GET("/announcements", middleware.UserAuth(), controller.GetUserAnnouncements) + apiRouter.POST("/announcements/:id/read", middleware.UserAuth(), controller.MarkAnnouncementRead) apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) apiRouter.GET("/notice", controller.GetNotice)