fix(*): 公告与画布 - 支持公告重新发布并阻止浏览器误缩放

Fixes #55
This commit is contained in:
ddCat committed 2026-07-31 15:46:58 +08:00
1 parent 90a903805f
commit bf339911e4
10 files changed
+206 -28

No files matched your search

+19
View File
@@ -80,6 +80,25 @@ func RegisterAnnouncementRoutes(r *gin.RouterGroup, svc *service.Service) {
ok(c, gin.H{"announcement": announcement})
})
r.PATCH("/admin/announcements/:id", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
var req service.UpdateAnnouncementRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
announcement, err := svc.UpdateAnnouncement(user, c.Param("id"), req)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"announcement": announcement})
})
r.POST("/admin/announcements/:id/close", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
@@ -60,6 +60,28 @@ func (r *Repository) CloseAnnouncement(id string, closedAt time.Time) (bool, err
return result.RowsAffected == 1, result.Error
}
func (r *Repository) UpdateAnnouncement(announcement *model.Announcement) error {
return r.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Announcement{}).Where("id = ?", announcement.ID).Updates(map[string]any{
"title": announcement.Title,
"content": announcement.Content,
"level": announcement.Level,
"status": announcement.Status,
"published_at": announcement.PublishedAt,
"closed_at": announcement.ClosedAt,
"updated_at": announcement.UpdatedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
// 重新发布同一公告必须清除旧已读记录,否则用户看不到新的内容提醒。
return tx.Where("announcement_id = ?", announcement.ID).Delete(&model.UserAnnouncementRead{}).Error
})
}
func (r *Repository) MarkAnnouncementsRead(userID string, announcementIDs []string, readAt time.Time) error {
if len(announcementIDs) == 0 {
return nil
+55 -14
View File
@@ -17,6 +17,8 @@ type CreateAnnouncementRequest struct {
Level model.AnnouncementLevel `json:"level"`
}
type UpdateAnnouncementRequest = CreateAnnouncementRequest
type AnnouncementPage struct {
Announcements []model.Announcement `json:"announcements"`
Total int64 `json:"total"`
@@ -45,20 +47,9 @@ func (s *Service) CreateAnnouncement(actor *model.User, req CreateAnnouncementRe
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
title := strings.TrimSpace(req.Title)
content := strings.TrimSpace(req.Content)
if title == "" || content == "" {
return nil, BadAuthRequest("请填写公告标题和正文")
}
if utf8.RuneCountInString(title) > 120 {
return nil, BadAuthRequest("公告标题不能超过 120 个字符")
}
if utf8.RuneCountInString(content) > 4000 {
return nil, BadAuthRequest("公告正文不能超过 4000 个字符")
}
level := req.Level
if !validAnnouncementLevel(level) {
return nil, BadAuthRequest("公告级别无效")
title, content, level, err := normalizeAnnouncementInput(req)
if err != nil {
return nil, err
}
now := time.Now()
announcement := &model.Announcement{
@@ -78,6 +69,38 @@ func (s *Service) CreateAnnouncement(actor *model.User, req CreateAnnouncementRe
return announcement, nil
}
func (s *Service) UpdateAnnouncement(actor *model.User, id string, req UpdateAnnouncementRequest) (*model.Announcement, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
announcement, err := s.repo.Announcement(strings.TrimSpace(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, BadAuthRequest("公告不存在")
}
return nil, err
}
title, content, level, err := normalizeAnnouncementInput(req)
if err != nil {
return nil, err
}
now := time.Now()
announcement.Title = title
announcement.Content = content
announcement.Level = level
announcement.Status = model.AnnouncementStatusActive
announcement.ClosedAt = nil
announcement.PublishedAt = now
announcement.UpdatedAt = now
if err := s.repo.UpdateAnnouncement(announcement); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, BadAuthRequest("公告状态已变化,请刷新后重试")
}
return nil, err
}
return s.repo.Announcement(announcement.ID)
}
func (s *Service) CloseAnnouncement(actor *model.User, id string) (*model.Announcement, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
@@ -136,3 +159,21 @@ func (s *Service) MarkAnnouncementsRead(user *model.User, announcementIDs []stri
func validAnnouncementLevel(level model.AnnouncementLevel) bool {
return level == model.AnnouncementLevelInfo || level == model.AnnouncementLevelSuccess || level == model.AnnouncementLevelWarning || level == model.AnnouncementLevelCritical
}
func normalizeAnnouncementInput(req CreateAnnouncementRequest) (string, string, model.AnnouncementLevel, error) {
title := strings.TrimSpace(req.Title)
content := strings.TrimSpace(req.Content)
if title == "" || content == "" {
return "", "", "", BadAuthRequest("请填写公告标题和正文")
}
if utf8.RuneCountInString(title) > 120 {
return "", "", "", BadAuthRequest("公告标题不能超过 120 个字符")
}
if utf8.RuneCountInString(content) > 4000 {
return "", "", "", BadAuthRequest("公告正文不能超过 4000 个字符")
}
if !validAnnouncementLevel(req.Level) {
return "", "", "", BadAuthRequest("公告级别无效")
}
return title, content, req.Level, nil
}
@@ -69,6 +69,50 @@ func TestAnnouncementPublishReadAndCloseLifecycle(t *testing.T) {
}
}
func TestAnnouncementUpdateRepublishesAndResetsReads(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}
announcement, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "旧标题", Content: "旧正文", Level: model.AnnouncementLevelInfo})
if err != nil {
t.Fatal(err)
}
if _, err := svc.MarkAnnouncementsRead(user, []string{announcement.ID}); err != nil {
t.Fatal(err)
}
if _, err := svc.CloseAnnouncement(admin, announcement.ID); err != nil {
t.Fatal(err)
}
updated, err := svc.UpdateAnnouncement(admin, announcement.ID, UpdateAnnouncementRequest{Title: "新标题", Content: "新正文", Level: model.AnnouncementLevelWarning})
if err != nil {
t.Fatal(err)
}
if updated.Status != model.AnnouncementStatusActive || updated.ClosedAt != nil || updated.Title != "新标题" || updated.Content != "新正文" || updated.Level != model.AnnouncementLevelWarning {
t.Fatalf("updated announcement = %+v", updated)
}
feed, err := svc.UserAnnouncements(user)
if err != nil {
t.Fatal(err)
}
if len(feed.Announcements) != 1 || feed.UnreadCount != 1 || feed.Announcements[0].Content != "新正文" {
t.Fatalf("feed after republish = %+v, want one unread updated announcement", feed)
}
}
func TestAnnouncementPublishRejectsInvalidInput(t *testing.T) {
svc := &Service{}
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}