diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index 716ef0aa4..efc6ada73 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -374,6 +374,7 @@ type Announcement struct { Title string `json:"title" gorm:"size:120"` Content string `json:"content" gorm:"type:text"` Level AnnouncementLevel `json:"level" gorm:"index;size:24"` + Pinned bool `json:"pinned" gorm:"index"` Status AnnouncementStatus `json:"status" gorm:"index;size:24;index:idx_announcements_status_published,priority:1"` CreatedBy string `json:"createdBy" gorm:"index;size:36"` PublishedAt time.Time `json:"publishedAt" gorm:"index:idx_announcements_status_published,priority:2"` diff --git a/backend/internal/repository/announcement.go b/backend/internal/repository/announcement.go index dce6d423b..a4c2bb69b 100644 --- a/backend/internal/repository/announcement.go +++ b/backend/internal/repository/announcement.go @@ -24,7 +24,7 @@ func (r *Repository) AdminAnnouncements(keyword string, status model.Announcemen if err := query.Count(&total).Error; err != nil { return nil, 0, err } - if err := query.Order("published_at desc").Limit(limit).Offset(offset).Find(&announcements).Error; err != nil { + if err := query.Order("pinned desc, published_at desc").Limit(limit).Offset(offset).Find(&announcements).Error; err != nil { return nil, 0, err } return announcements, total, nil @@ -34,7 +34,7 @@ func (r *Repository) AnnouncementFeed(userID string) ([]model.Announcement, int6 var announcements []model.Announcement var unreadCount int64 err := r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("status = ?", model.AnnouncementStatusActive).Order("published_at desc").Find(&announcements).Error; err != nil { + if err := tx.Where("status = ?", model.AnnouncementStatusActive).Order("pinned desc, published_at desc").Find(&announcements).Error; err != nil { return err } return tx.Model(&model.Announcement{}). @@ -66,6 +66,7 @@ func (r *Repository) UpdateAnnouncement(announcement *model.Announcement) error "title": announcement.Title, "content": announcement.Content, "level": announcement.Level, + "pinned": announcement.Pinned, "status": announcement.Status, "published_at": announcement.PublishedAt, "closed_at": announcement.ClosedAt, diff --git a/backend/internal/service/announcement.go b/backend/internal/service/announcement.go index 7ec78397b..0532d1f3c 100644 --- a/backend/internal/service/announcement.go +++ b/backend/internal/service/announcement.go @@ -15,6 +15,7 @@ type CreateAnnouncementRequest struct { Title string `json:"title"` Content string `json:"content"` Level model.AnnouncementLevel `json:"level"` + Pinned bool `json:"pinned"` } type UpdateAnnouncementRequest = CreateAnnouncementRequest @@ -57,6 +58,7 @@ func (s *Service) CreateAnnouncement(actor *model.User, req CreateAnnouncementRe Title: title, Content: content, Level: level, + Pinned: req.Pinned, Status: model.AnnouncementStatusActive, CreatedBy: actor.ID, PublishedAt: now, @@ -88,6 +90,7 @@ func (s *Service) UpdateAnnouncement(actor *model.User, id string, req UpdateAnn announcement.Title = title announcement.Content = content announcement.Level = level + announcement.Pinned = req.Pinned announcement.Status = model.AnnouncementStatusActive announcement.ClosedAt = nil announcement.PublishedAt = now diff --git a/backend/internal/service/announcement_test.go b/backend/internal/service/announcement_test.go index 238d891ae..b9307ddef 100644 --- a/backend/internal/service/announcement_test.go +++ b/backend/internal/service/announcement_test.go @@ -2,6 +2,7 @@ package service import ( "testing" + "time" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/repository" @@ -97,11 +98,11 @@ func TestAnnouncementUpdateRepublishesAndResetsReads(t *testing.T) { t.Fatal(err) } - updated, err := svc.UpdateAnnouncement(admin, announcement.ID, UpdateAnnouncementRequest{Title: "新标题", Content: "新正文", Level: model.AnnouncementLevelWarning}) + updated, err := svc.UpdateAnnouncement(admin, announcement.ID, UpdateAnnouncementRequest{Title: "新标题", Content: "新正文", Level: model.AnnouncementLevelWarning, Pinned: true}) if err != nil { t.Fatal(err) } - if updated.Status != model.AnnouncementStatusActive || updated.ClosedAt != nil || updated.Title != "新标题" || updated.Content != "新正文" || updated.Level != model.AnnouncementLevelWarning { + if updated.Status != model.AnnouncementStatusActive || updated.ClosedAt != nil || updated.Title != "新标题" || updated.Content != "新正文" || updated.Level != model.AnnouncementLevelWarning || !updated.Pinned { t.Fatalf("updated announcement = %+v", updated) } feed, err := svc.UserAnnouncements(user) @@ -113,6 +114,51 @@ func TestAnnouncementUpdateRepublishesAndResetsReads(t *testing.T) { } } +func TestPinnedAnnouncementsAreReturnedBeforeNewerRegularAnnouncements(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + sqlDB.SetMaxOpenConns(1) + if err := db.AutoMigrate(&model.User{}, &model.Announcement{}, &model.UserAnnouncementRead{}); err != nil { + t.Fatal(err) + } + svc := New(repository.New(db), t.TempDir()) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + user := &model.User{ID: "user", Role: model.UserRoleUser, Status: model.UserStatusActive} + + pinned, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "置顶", Content: "置顶公告", Level: model.AnnouncementLevelWarning, Pinned: true}) + if err != nil { + t.Fatal(err) + } + regular, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "普通", Content: "较新的普通公告", Level: model.AnnouncementLevelInfo}) + if err != nil { + t.Fatal(err) + } + if err := db.Model(&model.Announcement{}).Where("id = ?", pinned.ID).Update("published_at", regular.PublishedAt.Add(-time.Hour)).Error; err != nil { + t.Fatal(err) + } + + feed, err := svc.UserAnnouncements(user) + if err != nil { + t.Fatal(err) + } + if len(feed.Announcements) != 2 || feed.Announcements[0].ID != pinned.ID || !feed.Announcements[0].Pinned { + t.Fatalf("feed = %+v, want pinned announcement first", feed.Announcements) + } + page, err := svc.AdminAnnouncementPage(admin, AdminListQuery{Page: 1, Limit: 20}) + if err != nil { + t.Fatal(err) + } + if len(page.Announcements) != 2 || page.Announcements[0].ID != pinned.ID { + t.Fatalf("admin page = %+v, want pinned announcement first", page.Announcements) + } +} + func TestAnnouncementPublishRejectsInvalidInput(t *testing.T) { svc := &Service{} admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index df864eceb..0362940ac 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -49,6 +49,15 @@ description: 影策后端核心持久化表与状态边界 请求表保存工作流执行 payload 和结果,但不保存 Bridge Token。任务取消时,未领取请求可以确定取消;已经领取的本机工作流无法可靠中止,迟到结果会被丢弃并进入费用核对流程。 +## 系统公告 + +| 表 | 用途 | 关键边界 | +| --- | --- | --- | +| `announcements` | 保存管理员发布的系统公告 | `pinned` 只改变活动公告和管理列表的排序;公告正文、状态和重新发布时间仍由 service 校验 | +| `user_announcement_reads` | 保存用户已读状态 | 同一用户和公告唯一;公告重新发布时清除旧已读记录 | + +活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。 + ## 资源存储 | 表 | 用途 | 关键边界 | diff --git a/web/src/components/ui/aceternity/announcement-timeline-modal.tsx b/web/src/components/ui/aceternity/announcement-timeline-modal.tsx index a72b97c81..5605ecf17 100644 --- a/web/src/components/ui/aceternity/announcement-timeline-modal.tsx +++ b/web/src/components/ui/aceternity/announcement-timeline-modal.tsx @@ -1,6 +1,6 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Modal } from "antd"; -import { Bell, BellOff, CircleAlert, Info, ShieldAlert, Wrench } from "lucide-react"; +import { Bell, BellOff, CircleAlert, Info, Pin, ShieldAlert, Wrench } from "lucide-react"; import { aceternityMotion } from "@/lib/aceternity-motion"; import { AnnouncementContent } from "@/components/ui/announcement-content"; @@ -108,6 +108,7 @@ function AnnouncementTimelineItem({ announcement, last, reducedMotion }: { annou