From 0f274bb71f151e72ca548857a9b8c9e1a3744045 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 29 Aug 2026 15:16:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=B3=BB=E7=BB=9F=E5=85=AC?= =?UTF-8?q?=E5=91=8A=20-=20=E6=94=AF=E6=8C=81=E9=85=8D=E5=9B=BE=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E4=B8=8E=E8=87=AA=E5=8A=A8=E5=9B=9E=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapted from ddcat-ai/open-ai-canvas#339 Original commit: fcdbe2aa833f31f683f709b7f409a0534ed78e20 Refs #339 Co-authored-by: 宇熙 <101980758+yuxiwl@users.noreply.github.com> --- backend/cmd/migrate-sqlite-postgres/main.go | 1 + backend/internal/database/schema.go | 1 + backend/internal/handler/announcement.go | 63 ++++ backend/internal/model/models_project.go | 32 +- backend/internal/repository/announcement.go | 23 -- .../internal/repository/announcement_image.go | 158 +++++++++ backend/internal/service/announcement.go | 325 ++++++++++++++++-- backend/internal/service/announcement_test.go | 125 +++++++ .../service/resource_deletion_worker.go | 6 + .../content/docs/backend/backend-database.mdx | 5 +- .../announcement-timeline-modal.tsx | 5 +- .../components/admin-announcements-panel.tsx | 111 +++++- web/src/services/api/announcements.ts | 26 +- 13 files changed, 811 insertions(+), 70 deletions(-) create mode 100644 backend/internal/repository/announcement_image.go diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 6273f5442..834cddd7b 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -247,6 +247,7 @@ func migrations() []tableMigration { migrateTable[model.UserSkillState]("user_skill_states"), migrateTable[model.Resource]("resources"), migrateTable[model.ResourceDeletionJob]("resource_deletion_jobs"), + migrateTable[model.AnnouncementImageDraft]("announcement_image_drafts"), migrateTable[model.Asset]("assets"), migrateTable[model.ProjectAssetLink]("project_asset_links"), migrateTable[model.ProjectAssetFolder]("project_asset_folders"), diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index d06994d0e..d69b51e26 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -49,6 +49,7 @@ func Models() []any { &model.UserSkillState{}, &model.Resource{}, &model.ResourceDeletionJob{}, + &model.AnnouncementImageDraft{}, &model.Asset{}, &model.ProjectAssetLink{}, &model.ProjectAssetFolder{}, diff --git a/backend/internal/handler/announcement.go b/backend/internal/handler/announcement.go index acb309711..7aa4fd243 100644 --- a/backend/internal/handler/announcement.go +++ b/backend/internal/handler/announcement.go @@ -3,6 +3,7 @@ package handler import ( "net/http" "strconv" + "time" "infinite-canvas/backend/internal/service" @@ -23,6 +24,31 @@ func RegisterAnnouncementRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, feed) }) + r.GET("/announcements/:id/image", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + stream, err := svc.OpenAnnouncementImage(user, c.Param("id"), c.GetHeader("Range")) + if err != nil { + failService(c, err) + return + } + defer stream.Body.Close() + mimeType := stream.Resource.MimeType + if mimeType == "" { + mimeType = "application/octet-stream" + } + c.Header("Cache-Control", "private, max-age=3600") + c.Header("Referrer-Policy", "no-referrer") + c.Header("Accept-Ranges", stream.AcceptRanges) + c.Header("X-Content-Type-Options", "nosniff") + if stream.ContentRange != "" { + c.Header("Content-Range", stream.ContentRange) + } + c.DataFromReader(stream.StatusCode, stream.ContentLength, mimeType, stream.Body, nil) + }) r.POST("/announcements/read", func(c *gin.Context) { user, err := currentUser(c, svc) @@ -80,6 +106,43 @@ func RegisterAnnouncementRoutes(r *gin.RouterGroup, svc *service.Service) { ok(c, gin.H{"announcement": announcement}) }) + r.POST("/admin/announcement-images", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + policy, available := loadRuntimePolicy(c, svc) + if !available || !enforceRateLimit(c, "admin-announcement-image-upload:"+user.ID, policy.Request.ResourceUploadPerMinute, time.Minute) { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.AnnouncementImageMaxBytes+(1<<20)) + file, err := c.FormFile("file") + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + resource, err := svc.UploadAnnouncementImage(user, file) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"resource": resource}) + }) + + r.DELETE("/admin/announcement-images/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DiscardAnnouncementImage(user, c.Param("id")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"ok": true}) + }) + r.PATCH("/admin/announcements/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index efc6ada73..eddda7003 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -49,6 +49,14 @@ type ResourceDeletionJob struct { UpdatedAt time.Time `json:"updatedAt"` } +// AnnouncementImageDraft marks an uploaded image as temporary until an +// announcement create or update transaction consumes it. +type AnnouncementImageDraft struct { + ResourceID string `json:"resourceId" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` +} + type Asset struct { ID string `json:"id" gorm:"primaryKey;size:80"` UserID string `json:"userId" gorm:"index;size:36;index:idx_assets_user_updated,priority:1"` @@ -370,17 +378,19 @@ type UserPromptCustomization struct { } type Announcement struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - 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"` - ClosedAt *time.Time `json:"closedAt"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id" gorm:"primaryKey;size:36"` + Title string `json:"title" gorm:"size:120"` + Content string `json:"content" gorm:"type:text"` + ImageResourceID string `json:"imageResourceId,omitempty" gorm:"index;size:36"` + ImageURL string `json:"imageUrl,omitempty" gorm:"-"` + 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"` + ClosedAt *time.Time `json:"closedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type UserAnnouncementRead struct { diff --git a/backend/internal/repository/announcement.go b/backend/internal/repository/announcement.go index a4c2bb69b..9bd9e1e6c 100644 --- a/backend/internal/repository/announcement.go +++ b/backend/internal/repository/announcement.go @@ -60,29 +60,6 @@ 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, - "pinned": announcement.Pinned, - "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 diff --git a/backend/internal/repository/announcement_image.go b/backend/internal/repository/announcement_image.go new file mode 100644 index 000000000..75312cbdc --- /dev/null +++ b/backend/internal/repository/announcement_image.go @@ -0,0 +1,158 @@ +package repository + +import ( + "errors" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +var ErrAnnouncementImageDraftUnavailable = errors.New("announcement image draft unavailable") +var ErrAnnouncementImageReferenced = errors.New("announcement image is still referenced") + +func (r *Repository) CreateAnnouncementImageDraft(draft *model.AnnouncementImageDraft) error { + return r.db.Create(draft).Error +} + +func (r *Repository) AnnouncementImageDraftForUser(userID string, resourceID string) (*model.AnnouncementImageDraft, error) { + var draft model.AnnouncementImageDraft + if err := r.db.First(&draft, "resource_id = ? AND user_id = ?", resourceID, userID).Error; err != nil { + return nil, err + } + return &draft, nil +} + +func (r *Repository) DeleteAnnouncementImageDraft(userID string, resourceID string) error { + return r.db.Where("resource_id = ? AND user_id = ?", resourceID, userID).Delete(&model.AnnouncementImageDraft{}).Error +} + +func (r *Repository) StaleAnnouncementImageDrafts(before time.Time, limit int) ([]model.AnnouncementImageDraft, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + var drafts []model.AnnouncementImageDraft + err := r.db.Where("created_at < ?", before).Order("created_at asc").Limit(limit).Find(&drafts).Error + return drafts, err +} + +func (r *Repository) CreateAnnouncementWithImage(announcement *model.Announcement) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := consumeAnnouncementImageDraft(tx, announcement.CreatedBy, announcement.ImageResourceID); err != nil { + return err + } + return tx.Create(announcement).Error + }) +} + +func (r *Repository) UpdateAnnouncementWithImage(announcement *model.Announcement, draftUserID string, newDraftResourceID string, oldResource *model.Resource, deletionJob *model.ResourceDeletionJob) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := consumeAnnouncementImageDraft(tx, draftUserID, newDraftResourceID); err != nil { + return err + } + result := tx.Model(&model.Announcement{}).Where("id = ?", announcement.ID).Updates(map[string]any{ + "title": announcement.Title, + "content": announcement.Content, + "image_resource_id": announcement.ImageResourceID, + "level": announcement.Level, + "pinned": announcement.Pinned, + "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 + } + if err := tx.Where("announcement_id = ?", announcement.ID).Delete(&model.UserAnnouncementRead{}).Error; err != nil { + return err + } + if oldResource == nil { + return nil + } + var referenceCount int64 + if err := tx.Model(&model.Announcement{}).Where("image_resource_id = ?", oldResource.ID).Count(&referenceCount).Error; err != nil { + return err + } + if referenceCount > 0 { + return ErrAnnouncementImageReferenced + } + if err := tx.Where("resource_id = ?", oldResource.ID).Delete(&model.ArkPrivateAssetBinding{}).Error; err != nil { + return err + } + if err := tx.Where("resource_id = ?", oldResource.ID).Delete(&model.AnnouncementImageDraft{}).Error; err != nil { + return err + } + deleted := tx.Where("id = ? AND user_id = ?", oldResource.ID, oldResource.UserID).Delete(&model.Resource{}) + if deleted.Error != nil { + return deleted.Error + } + if deleted.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + if deletionJob != nil { + return tx.Create(deletionJob).Error + } + return nil + }) +} + +func (r *Repository) DiscardAnnouncementImageDraft(userID string, resource *model.Resource, deletionJob *model.ResourceDeletionJob) error { + if resource == nil { + return gorm.ErrRecordNotFound + } + return r.db.Transaction(func(tx *gorm.DB) error { + var draft model.AnnouncementImageDraft + if err := tx.First(&draft, "resource_id = ? AND user_id = ?", resource.ID, userID).Error; err != nil { + return err + } + var referenceCount int64 + if err := tx.Model(&model.Announcement{}).Where("image_resource_id = ?", resource.ID).Count(&referenceCount).Error; err != nil { + return err + } + if referenceCount > 0 { + return ErrAnnouncementImageReferenced + } + if err := tx.Where("resource_id = ?", resource.ID).Delete(&model.ArkPrivateAssetBinding{}).Error; err != nil { + return err + } + deleted := tx.Where("id = ? AND user_id = ?", resource.ID, userID).Delete(&model.Resource{}) + if deleted.Error != nil { + return deleted.Error + } + if deleted.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + if err := tx.Delete(&draft).Error; err != nil { + return err + } + if deletionJob != nil { + return tx.Create(deletionJob).Error + } + return nil + }) +} + +func (r *Repository) AnnouncementImageReferenceCount(resourceID string) (int64, error) { + var count int64 + err := r.db.Model(&model.Announcement{}).Where("image_resource_id = ?", resourceID).Count(&count).Error + return count, err +} + +func consumeAnnouncementImageDraft(tx *gorm.DB, userID string, resourceID string) error { + if resourceID == "" { + return nil + } + result := tx.Where("resource_id = ? AND user_id = ?", resourceID, userID).Delete(&model.AnnouncementImageDraft{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrAnnouncementImageDraftUnavailable + } + return nil +} diff --git a/backend/internal/service/announcement.go b/backend/internal/service/announcement.go index 0532d1f3c..f8c5630f7 100644 --- a/backend/internal/service/announcement.go +++ b/backend/internal/service/announcement.go @@ -2,20 +2,29 @@ package service import ( "errors" + "fmt" + "log" + "mime/multipart" + "net/http" "strings" "time" "unicode/utf8" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" "gorm.io/gorm" ) +const AnnouncementImageMaxBytes int64 = 10 << 20 +const announcementImageDraftTTL = 24 * time.Hour + type CreateAnnouncementRequest struct { - Title string `json:"title"` - Content string `json:"content"` - Level model.AnnouncementLevel `json:"level"` - Pinned bool `json:"pinned"` + Title string `json:"title"` + Content string `json:"content"` + ImageResourceID string `json:"imageResourceId"` + Level model.AnnouncementLevel `json:"level"` + Pinned bool `json:"pinned"` } type UpdateAnnouncementRequest = CreateAnnouncementRequest @@ -41,6 +50,9 @@ func (s *Service) AdminAnnouncementPage(actor *model.User, query AdminListQuery) if err != nil { return nil, err } + for index := range announcements { + decorateAnnouncement(&announcements[index]) + } return &AnnouncementPage{Announcements: announcements, Total: total, Page: page, Limit: limit}, nil } @@ -52,22 +64,26 @@ func (s *Service) CreateAnnouncement(actor *model.User, req CreateAnnouncementRe if err != nil { return nil, err } - now := time.Now() - announcement := &model.Announcement{ - ID: newID(), - Title: title, - Content: content, - Level: level, - Pinned: req.Pinned, - Status: model.AnnouncementStatusActive, - CreatedBy: actor.ID, - PublishedAt: now, - CreatedAt: now, - UpdatedAt: now, + if strings.TrimSpace(req.ImageResourceID) != "" { + s.storageMu.Lock() + defer s.storageMu.Unlock() } - if err := s.repo.Create(announcement); err != nil { + imageResourceID, err := s.validateAnnouncementImageDraft(actor, req.ImageResourceID) + if err != nil { return nil, err } + now := time.Now() + announcement := &model.Announcement{ + ID: newID(), Title: title, Content: content, ImageResourceID: imageResourceID, Level: level, Pinned: req.Pinned, + Status: model.AnnouncementStatusActive, CreatedBy: actor.ID, PublishedAt: now, CreatedAt: now, UpdatedAt: now, + } + if err := s.repo.CreateAnnouncementWithImage(announcement); err != nil { + if errors.Is(err, repository.ErrAnnouncementImageDraftUnavailable) { + return nil, BadAuthRequest("公告配图草稿已失效,请重新上传") + } + return nil, err + } + decorateAnnouncement(announcement) return announcement, nil } @@ -75,6 +91,8 @@ func (s *Service) UpdateAnnouncement(actor *model.User, id string, req UpdateAnn if err := s.RequireAdmin(actor); err != nil { return nil, err } + s.storageMu.Lock() + defer s.storageMu.Unlock() announcement, err := s.repo.Announcement(strings.TrimSpace(id)) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -86,22 +104,66 @@ func (s *Service) UpdateAnnouncement(actor *model.User, id string, req UpdateAnn if err != nil { return nil, err } + imageResourceID := strings.TrimSpace(req.ImageResourceID) + newDraftResourceID := "" + if imageResourceID != announcement.ImageResourceID { + if imageResourceID != "" { + imageResourceID, err = s.validateAnnouncementImageDraft(actor, imageResourceID) + if err != nil { + return nil, err + } + newDraftResourceID = imageResourceID + } + } + var oldResource *model.Resource + var deletionJob *model.ResourceDeletionJob + if announcement.ImageResourceID != "" && announcement.ImageResourceID != imageResourceID { + oldResource, err = s.repo.Resource(announcement.ImageResourceID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, BadAuthRequest("原公告配图资源不存在,已停止更新以避免数据不一致") + } + return nil, err + } + if err := s.ensureResourceHasNoBusinessReferences(oldResource); err != nil { + return nil, err + } + sharedCount, countErr := s.repo.ResourceStorageReferenceCount(oldResource, []string{oldResource.ID}) + if countErr != nil { + return nil, countErr + } + if sharedCount == 0 { + jobs := resourceDeletionJobs(oldResource.UserID, map[string]*model.Resource{resourceStorageIdentity(oldResource): oldResource}) + if len(jobs) != 1 { + return nil, errors.New("无法创建公告配图删除任务") + } + deletionJob = &jobs[0] + } + } now := time.Now() announcement.Title = title announcement.Content = content + announcement.ImageResourceID = imageResourceID announcement.Level = level announcement.Pinned = req.Pinned announcement.Status = model.AnnouncementStatusActive announcement.ClosedAt = nil announcement.PublishedAt = now announcement.UpdatedAt = now - if err := s.repo.UpdateAnnouncement(announcement); err != nil { + if err := s.repo.UpdateAnnouncementWithImage(announcement, actor.ID, newDraftResourceID, oldResource, deletionJob); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, BadAuthRequest("公告状态已变化,请刷新后重试") } + if errors.Is(err, repository.ErrAnnouncementImageDraftUnavailable) { + return nil, BadAuthRequest("公告配图草稿已失效,请重新上传") + } + if errors.Is(err, repository.ErrAnnouncementImageReferenced) { + return nil, BadAuthRequest("原公告配图仍被其他公告引用,已停止更新") + } return nil, err } - return s.repo.Announcement(announcement.ID) + decorateAnnouncement(announcement) + return announcement, nil } func (s *Service) CloseAnnouncement(actor *model.User, id string) (*model.Announcement, error) { @@ -125,7 +187,12 @@ func (s *Service) CloseAnnouncement(actor *model.User, id string) (*model.Announ if !updated { return nil, BadAuthRequest("公告状态已变化,请刷新后重试") } - return s.repo.Announcement(announcement.ID) + closed, err := s.repo.Announcement(announcement.ID) + if err != nil { + return nil, err + } + decorateAnnouncement(closed) + return closed, nil } func (s *Service) UserAnnouncements(user *model.User) (*UserAnnouncementFeed, error) { @@ -136,9 +203,224 @@ func (s *Service) UserAnnouncements(user *model.User) (*UserAnnouncementFeed, er if err != nil { return nil, err } + for index := range announcements { + decorateAnnouncement(&announcements[index]) + } return &UserAnnouncementFeed{Announcements: announcements, UnreadCount: unreadCount}, nil } +func (s *Service) UploadAnnouncementImage(actor *model.User, header *multipart.FileHeader) (*model.Resource, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + if err := validateAnnouncementImageUpload(header); err != nil { + return nil, err + } + resource, err := s.UploadResource(actor.ID, header, "image", 0, 0, 0) + if err != nil { + return nil, err + } + draft := &model.AnnouncementImageDraft{ResourceID: resource.ID, UserID: actor.ID, CreatedAt: time.Now()} + if err := s.repo.CreateAnnouncementImageDraft(draft); err != nil { + cleanupErr := s.deleteFreshAnnouncementImageResource(resource) + if cleanupErr != nil { + return nil, errors.Join(err, fmt.Errorf("清理未登记的公告配图失败:%w", cleanupErr)) + } + return nil, err + } + resource.PublicURL = "" + return resource, nil +} + +func (s *Service) DiscardAnnouncementImage(actor *model.User, resourceID string) error { + if err := s.RequireAdmin(actor); err != nil { + return err + } + return s.discardAnnouncementImageDraft(actor.ID, resourceID) +} + +func (s *Service) OpenAnnouncementImage(actor *model.User, announcementID string, rangeHeader string) (*ResourceStream, error) { + if actor == nil { + return nil, Unauthorized("请先登录") + } + announcement, err := s.repo.Announcement(strings.TrimSpace(announcementID)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, NotFound("公告不存在") + } + return nil, err + } + if actor.Role != model.UserRoleAdmin && announcement.Status != model.AnnouncementStatusActive { + return nil, Forbidden("公告不可访问") + } + if announcement.ImageResourceID == "" { + return nil, NotFound("公告配图不存在") + } + resource, err := s.repo.Resource(announcement.ImageResourceID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, NotFound("公告配图不存在") + } + return nil, err + } + if resource.Kind != "image" || resource.Status != model.ResourceStatusReady { + return nil, BadAuthRequest("公告配图资源不可用") + } + return s.openResourceRange(resource.UserID, resource, rangeHeader) +} + +func (s *Service) cleanupStaleAnnouncementImageDrafts() { + for { + drafts, err := s.repo.StaleAnnouncementImageDrafts(time.Now().Add(-announcementImageDraftTTL), 50) + if err != nil { + log.Printf("announcement image draft cleanup query failed: %v", err) + return + } + if len(drafts) == 0 { + return + } + cleaned := 0 + for _, draft := range drafts { + if err := s.discardAnnouncementImageDraft(draft.UserID, draft.ResourceID); err != nil { + log.Printf("announcement image draft cleanup failed for %s: %v", draft.ResourceID, err) + continue + } + cleaned++ + } + if len(drafts) < 50 || cleaned == 0 { + return + } + } +} + +func (s *Service) discardAnnouncementImageDraft(userID string, resourceID string) error { + resourceID = strings.TrimSpace(resourceID) + if resourceID == "" || len(resourceID) > 64 { + return BadAuthRequest("公告配图资源 ID 无效") + } + s.storageMu.Lock() + defer s.storageMu.Unlock() + if _, err := s.repo.AnnouncementImageDraftForUser(userID, resourceID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return NotFound("公告配图草稿不存在") + } + return err + } + resource, err := s.repo.ResourceForUser(userID, resourceID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + if deleteErr := s.repo.DeleteAnnouncementImageDraft(userID, resourceID); deleteErr != nil { + return errors.Join(err, fmt.Errorf("清理失效公告配图草稿失败:%w", deleteErr)) + } + return nil + } + return err + } + if err := s.ensureResourceHasNoBusinessReferences(resource); err != nil { + return err + } + sharedCount, err := s.repo.ResourceStorageReferenceCount(resource, []string{resource.ID}) + if err != nil { + return err + } + var deletionJob *model.ResourceDeletionJob + if sharedCount == 0 { + jobs := resourceDeletionJobs(userID, map[string]*model.Resource{resourceStorageIdentity(resource): resource}) + if len(jobs) != 1 { + return errors.New("无法创建公告配图删除任务") + } + deletionJob = &jobs[0] + } + if err := s.repo.DiscardAnnouncementImageDraft(userID, resource, deletionJob); err != nil { + if errors.Is(err, repository.ErrAnnouncementImageReferenced) { + return BadAuthRequest("公告配图已经发布,不能按草稿删除") + } + return err + } + return nil +} + +func (s *Service) ensureResourceHasNoBusinessReferences(resource *model.Resource) error { + if resource == nil { + return NotFound("资源不存在") + } + snapshot, err := s.repo.ResourceReferenceSnapshot(resource.UserID, "", []string{resource.ID}) + if err != nil { + return err + } + if len(snapshot.Direct) > 0 { + return BadAuthRequest("公告配图仍被其他业务数据引用,已停止删除") + } + resourceIDs := map[string]struct{}{resource.ID: {}} + for _, document := range snapshot.Documents { + if documentReferencesResources(document.PrimaryJSON, resourceIDs) || documentReferencesResources(document.SecondaryJSON, resourceIDs) { + return BadAuthRequest("公告配图仍被其他业务数据引用,已停止删除") + } + } + return nil +} + +func (s *Service) validateAnnouncementImageDraft(actor *model.User, resourceID string) (string, error) { + resourceID = strings.TrimSpace(resourceID) + if resourceID == "" { + return "", nil + } + if len(resourceID) > 64 { + return "", BadAuthRequest("公告配图资源 ID 无效") + } + if _, err := s.repo.AnnouncementImageDraftForUser(actor.ID, resourceID); err != nil { + return "", BadAuthRequest("公告配图草稿不存在或不属于当前管理员") + } + resource, err := s.repo.ResourceForUser(actor.ID, resourceID) + if err != nil { + return "", BadAuthRequest("公告配图资源不存在或不属于当前管理员") + } + if resource.Kind != "image" || resource.Status != model.ResourceStatusReady || !strings.HasPrefix(strings.ToLower(resource.MimeType), "image/") { + return "", BadAuthRequest("公告配图必须是上传完成的图片") + } + return resourceID, nil +} + +func validateAnnouncementImageUpload(header *multipart.FileHeader) error { + if header == nil { + return BadAuthRequest("请选择公告配图") + } + if header.Size <= 0 || header.Size > AnnouncementImageMaxBytes { + return BadAuthRequest("公告配图大小必须在 10MB 以内") + } + file, err := header.Open() + if err != nil { + return err + } + defer file.Close() + buffer := make([]byte, 512) + read, err := file.Read(buffer) + if err != nil && read == 0 { + return BadAuthRequest("公告配图内容无法读取") + } + if detected := http.DetectContentType(buffer[:read]); !strings.HasPrefix(strings.ToLower(detected), "image/") { + return BadAuthRequest("公告配图必须是真实图片文件") + } + return nil +} + +func (s *Service) deleteFreshAnnouncementImageResource(resource *model.Resource) error { + if resource == nil { + return nil + } + if err := s.deleteStoredResourceObject(resource.UserID, resource); err != nil { + return err + } + return s.repo.DeleteResource(resource.UserID, resource.ID) +} + +func decorateAnnouncement(announcement *model.Announcement) { + if announcement == nil || strings.TrimSpace(announcement.ImageResourceID) == "" { + return + } + announcement.ImageURL = "/api/announcements/" + announcement.ID + "/image" +} + func (s *Service) MarkAnnouncementsRead(user *model.User, announcementIDs []string) (int64, error) { if user == nil { return 0, Unauthorized("请先登录") @@ -178,5 +460,8 @@ func normalizeAnnouncementInput(req CreateAnnouncementRequest) (string, string, if !validAnnouncementLevel(req.Level) { return "", "", "", BadAuthRequest("公告级别无效") } + if len(strings.TrimSpace(req.ImageResourceID)) > 64 { + return "", "", "", BadAuthRequest("公告配图资源 ID 无效") + } return title, content, req.Level, nil } diff --git a/backend/internal/service/announcement_test.go b/backend/internal/service/announcement_test.go index b9307ddef..bf76bca51 100644 --- a/backend/internal/service/announcement_test.go +++ b/backend/internal/service/announcement_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "infinite-canvas/backend/internal/database" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/repository" @@ -159,6 +160,97 @@ func TestPinnedAnnouncementsAreReturnedBeforeNewerRegularAnnouncements(t *testin } } +func TestAnnouncementImageDraftIsConsumedWhenAnnouncementIsCreated(t *testing.T) { + svc, db := newAnnouncementImageTestService(t) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + resource := createAnnouncementImageDraft(t, db, admin.ID, "image-draft") + + announcement, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{ + Title: "带图公告", Content: "公告正文", ImageResourceID: resource.ID, Level: model.AnnouncementLevelInfo, + }) + if err != nil { + t.Fatal(err) + } + if announcement.ImageResourceID != resource.ID || announcement.ImageURL != "/api/announcements/"+announcement.ID+"/image" { + t.Fatalf("announcement image = %+v", announcement) + } + var draftCount int64 + if err := db.Model(&model.AnnouncementImageDraft{}).Where("resource_id = ?", resource.ID).Count(&draftCount).Error; err != nil { + t.Fatal(err) + } + if draftCount != 0 { + t.Fatalf("draft count = %d, want 0", draftCount) + } +} + +func TestAnnouncementImageReplacementQueuesOldResourceDeletionAtomically(t *testing.T) { + svc, db := newAnnouncementImageTestService(t) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + oldResource := createAnnouncementImageDraft(t, db, admin.ID, "old-image") + announcement, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{ + Title: "旧公告", Content: "旧正文", ImageResourceID: oldResource.ID, Level: model.AnnouncementLevelInfo, + }) + if err != nil { + t.Fatal(err) + } + newResource := createAnnouncementImageDraft(t, db, admin.ID, "new-image") + + updated, err := svc.UpdateAnnouncement(admin, announcement.ID, UpdateAnnouncementRequest{ + Title: "新公告", Content: "新正文", ImageResourceID: newResource.ID, Level: model.AnnouncementLevelWarning, Pinned: true, + }) + if err != nil { + t.Fatal(err) + } + if updated.ImageResourceID != newResource.ID || updated.ImageURL == "" { + t.Fatalf("updated announcement = %+v", updated) + } + var oldResourceCount int64 + if err := db.Model(&model.Resource{}).Where("id = ?", oldResource.ID).Count(&oldResourceCount).Error; err != nil { + t.Fatal(err) + } + if oldResourceCount != 0 { + t.Fatalf("old resource count = %d, want 0", oldResourceCount) + } + var deletionJobs []model.ResourceDeletionJob + if err := db.Where("resource_id = ?", oldResource.ID).Find(&deletionJobs).Error; err != nil { + t.Fatal(err) + } + if len(deletionJobs) != 1 || deletionJobs[0].ObjectKey != oldResource.ObjectKey { + t.Fatalf("deletion jobs = %+v", deletionJobs) + } + var draftCount int64 + if err := db.Model(&model.AnnouncementImageDraft{}).Where("resource_id = ?", newResource.ID).Count(&draftCount).Error; err != nil { + t.Fatal(err) + } + if draftCount != 0 { + t.Fatalf("new image draft count = %d, want 0", draftCount) + } +} + +func TestDiscardAnnouncementImageDraftRemovesRecordAndQueuesDeletion(t *testing.T) { + svc, db := newAnnouncementImageTestService(t) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + resource := createAnnouncementImageDraft(t, db, admin.ID, "cancelled-image") + + if err := svc.DiscardAnnouncementImage(admin, resource.ID); err != nil { + t.Fatal(err) + } + var resourceCount int64 + if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil { + t.Fatal(err) + } + if resourceCount != 0 { + t.Fatalf("resource count = %d, want 0", resourceCount) + } + var jobCount int64 + if err := db.Model(&model.ResourceDeletionJob{}).Where("resource_id = ?", resource.ID).Count(&jobCount).Error; err != nil { + t.Fatal(err) + } + if jobCount != 1 { + t.Fatalf("deletion job count = %d, want 1", jobCount) + } +} + func TestAnnouncementPublishRejectsInvalidInput(t *testing.T) { svc := &Service{} admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} @@ -169,3 +261,36 @@ func TestAnnouncementPublishRejectsInvalidInput(t *testing.T) { t.Fatal("expected invalid level to be rejected") } } + +func newAnnouncementImageTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + 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(database.Models()...); err != nil { + t.Fatal(err) + } + return New(repository.New(db), t.TempDir()), db +} + +func createAnnouncementImageDraft(t *testing.T, db *gorm.DB, userID string, id string) *model.Resource { + t.Helper() + resource := &model.Resource{ + ID: id, UserID: userID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", + ObjectKey: "users/" + userID + "/image/" + id + ".png", MimeType: "image/png", Size: 128, + CreatedAt: time.Now(), UpdatedAt: time.Now(), + } + if err := db.Create(resource).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.AnnouncementImageDraft{ResourceID: resource.ID, UserID: userID, CreatedAt: time.Now()}).Error; err != nil { + t.Fatal(err) + } + return resource +} diff --git a/backend/internal/service/resource_deletion_worker.go b/backend/internal/service/resource_deletion_worker.go index fa7ac520a..f572f0a0b 100644 --- a/backend/internal/service/resource_deletion_worker.go +++ b/backend/internal/service/resource_deletion_worker.go @@ -13,10 +13,16 @@ const resourceDeletionLease = 2 * time.Minute func (s *Service) startResourceDeletionWorker() { go func() { s.drainResourceDeletionJobs(32) + s.cleanupStaleAnnouncementImageDrafts() ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() + lastDraftCleanup := time.Now() for range ticker.C { s.drainResourceDeletionJobs(32) + if time.Since(lastDraftCleanup) >= time.Hour { + s.cleanupStaleAnnouncementImageDrafts() + lastDraftCleanup = time.Now() + } } }() } diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 0362940ac..0df85e13c 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -53,10 +53,11 @@ description: 影策后端核心持久化表与状态边界 | 表 | 用途 | 关键边界 | | --- | --- | --- | -| `announcements` | 保存管理员发布的系统公告 | `pinned` 只改变活动公告和管理列表的排序;公告正文、状态和重新发布时间仍由 service 校验 | +| `announcements` | 保存管理员发布的系统公告 | `pinned` 只改变排序;`image_resource_id` 只能绑定由当前管理员上传并在发布事务中消费的公告配图草稿 | +| `announcement_image_drafts` | 标记尚未发布的公告配图资源 | 发布/更新事务消费草稿;取消编辑显式回收,超过 24 小时的异常遗留草稿由后台清理 | | `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 5605ecf17..b7b830f16 100644 --- a/web/src/components/ui/aceternity/announcement-timeline-modal.tsx +++ b/web/src/components/ui/aceternity/announcement-timeline-modal.tsx @@ -1,10 +1,10 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { Modal } from "antd"; +import { Image as AntImage, Modal } from "antd"; import { Bell, BellOff, CircleAlert, Info, Pin, ShieldAlert, Wrench } from "lucide-react"; import { aceternityMotion } from "@/lib/aceternity-motion"; import { AnnouncementContent } from "@/components/ui/announcement-content"; -import type { AnnouncementLevel, SystemAnnouncement } from "@/services/api/announcements"; +import { announcementImageUrl, type AnnouncementLevel, type SystemAnnouncement } from "@/services/api/announcements"; type AnnouncementTimelineModalProps = { open: boolean; @@ -110,6 +110,7 @@ function AnnouncementTimelineItem({ announcement, last, reducedMotion }: { annou {meta.label} {announcement.pinned ? 置顶 : null} + {announcement.imageUrl ? : null} diff --git a/web/src/pages/admin/components/admin-announcements-panel.tsx b/web/src/pages/admin/components/admin-announcements-panel.tsx index 948aedb5e..834413273 100644 --- a/web/src/pages/admin/components/admin-announcements-panel.tsx +++ b/web/src/pages/admin/components/admin-announcements-panel.tsx @@ -1,24 +1,29 @@ import { App, Button, Form, Input, Modal, Select, Switch, Tag } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { Pin, Plus, Search } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { Pin, Plus, Search, Upload, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type ChangeEvent } from "react"; import { PaginationBar } from "@/components/layout/workspace-page"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { + announcementImageUrl, closeAdminAnnouncement, createAdminAnnouncement, + discardAdminAnnouncementImage, listAdminAnnouncements, + uploadAdminAnnouncementImage, updateAdminAnnouncement, type AnnouncementLevel, type AnnouncementStatus, type SystemAnnouncement, } from "@/services/api/announcements"; +import { resourceFileUrl } from "@/services/api/resources"; import { AdminDataTable, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "./admin-ui"; type AnnouncementFormValues = { title: string; content: string; + imageResourceId?: string; level: AnnouncementLevel; pinned: boolean; }; @@ -52,6 +57,10 @@ export default function AdminAnnouncementsPanel() { const [editingAnnouncement, setEditingAnnouncement] = useState(null); const [publishing, setPublishing] = useState(false); const [closingId, setClosingId] = useState(""); + const [imagePreviewUrl, setImagePreviewUrl] = useState(""); + const [draftImageResourceId, setDraftImageResourceId] = useState(""); + const [imageUploading, setImageUploading] = useState(false); + const imageInputRef = useRef(null); const reload = useCallback(async () => { setLoading(true); @@ -72,23 +81,89 @@ export default function AdminAnnouncementsPanel() { const openPublishModal = () => { setEditingAnnouncement(null); - form.setFieldsValue({ title: "", content: "", level: "info", pinned: false }); + form.setFieldsValue({ title: "", content: "", imageResourceId: "", level: "info", pinned: false }); + setImagePreviewUrl(""); + setDraftImageResourceId(""); setModalOpen(true); }; const openEditModal = (announcement: SystemAnnouncement) => { setEditingAnnouncement(announcement); - form.setFieldsValue({ title: announcement.title, content: announcement.content, level: announcement.level, pinned: announcement.pinned }); + form.setFieldsValue({ title: announcement.title, content: announcement.content, imageResourceId: announcement.imageResourceId || "", level: announcement.level, pinned: announcement.pinned }); + setImagePreviewUrl(announcementImageUrl(announcement)); + setDraftImageResourceId(""); setModalOpen(true); }; + const discardDraftImage = async () => { + if (!draftImageResourceId) return; + await discardAdminAnnouncementImage(draftImageResourceId); + setDraftImageResourceId(""); + }; + + const uploadAnnouncementImage = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + if (!file.type.startsWith("image/")) { + message.warning("公告配图必须是图片文件"); + return; + } + if (file.size > 10 * 1024 * 1024) { + message.warning("公告配图不能超过 10MB"); + return; + } + setImageUploading(true); + try { + if (draftImageResourceId) await discardDraftImage(); + const { resource } = await uploadAdminAnnouncementImage(file); + form.setFieldValue("imageResourceId", resource.id); + setDraftImageResourceId(resource.id); + setImagePreviewUrl(resourceFileUrl(resource.id)); + message.success("公告配图已上传"); + } catch (error) { + message.error(error instanceof Error ? error.message : "公告配图上传失败"); + } finally { + setImageUploading(false); + } + }; + + const clearAnnouncementImage = async () => { + setImageUploading(true); + try { + await discardDraftImage(); + form.setFieldValue("imageResourceId", ""); + setImagePreviewUrl(""); + } catch (error) { + message.error(error instanceof Error ? error.message : "公告配图清理失败"); + } finally { + setImageUploading(false); + } + }; + + const closeEditor = async () => { + setImageUploading(true); + try { + await discardDraftImage(); + setModalOpen(false); + setEditingAnnouncement(null); + setImagePreviewUrl(""); + form.resetFields(); + } catch (error) { + message.error(error instanceof Error ? error.message : "公告配图草稿清理失败,请重试"); + } finally { + setImageUploading(false); + } + }; + const publish = async () => { const values = await form.validateFields(); setPublishing(true); try { - const input = { title: values.title.trim(), content: values.content.trim(), level: values.level, pinned: Boolean(values.pinned) }; + const input = { title: values.title.trim(), content: values.content.trim(), imageResourceId: values.imageResourceId?.trim() || "", level: values.level, pinned: Boolean(values.pinned) }; if (editingAnnouncement) await updateAdminAnnouncement(editingAnnouncement.id, input); else await createAdminAnnouncement(input); + setDraftImageResourceId(""); setModalOpen(false); setEditingAnnouncement(null); setPage(1); @@ -120,9 +195,12 @@ export default function AdminAnnouncementsPanel() { dataIndex: "title", minWidth: 360, render: (_, announcement) => ( -
-
{announcement.title}
-
{announcement.content}
+
+ {announcement.imageUrl ? : null} +
+
{announcement.title}
+
{announcement.content}
+
), }, @@ -181,7 +259,7 @@ export default function AdminAnnouncementsPanel() { footer={ { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />} /> - void publish()} onCancel={() => { setModalOpen(false); setEditingAnnouncement(null); }} destroyOnHidden> + void publish()} onCancel={() => void closeEditor()} destroyOnHidden>
@@ -192,6 +270,21 @@ export default function AdminAnnouncementsPanel() { + + +
+ {imagePreviewUrl ? ( +
+ 公告配图预览 +
+ ) :
暂未添加配图
} + void uploadAnnouncementImage(event)} /> + +
+
diff --git a/web/src/services/api/announcements.ts b/web/src/services/api/announcements.ts index 8ed9905a6..360b8f91c 100644 --- a/web/src/services/api/announcements.ts +++ b/web/src/services/api/announcements.ts @@ -1,4 +1,5 @@ -import { apiClient, request } from "@/services/api/request"; +import { apiBaseURL, apiClient, request } from "@/services/api/request"; +import type { RemoteResource } from "@/services/api/resources"; export type AnnouncementLevel = "info" | "success" | "warning" | "critical"; export type AnnouncementStatus = "active" | "closed"; @@ -7,6 +8,8 @@ export type SystemAnnouncement = { id: string; title: string; content: string; + imageResourceId?: string; + imageUrl?: string; level: AnnouncementLevel; pinned: boolean; status: AnnouncementStatus; @@ -35,6 +38,13 @@ export function getAnnouncementFeed() { return request(api.get("/announcements")); } +export function announcementImageUrl(announcement: Pick) { + const imageUrl = announcement.imageUrl; + if (!imageUrl || !imageUrl.startsWith("/api/")) return imageUrl || ""; + const base = String(apiBaseURL).replace(/\/+$/, ""); + return base === "/api" ? imageUrl : `${base}${imageUrl.slice("/api".length)}`; +} + export function markAnnouncementsRead(announcementIds: string[]) { return request<{ unreadCount: number }>(api.post("/announcements/read", { announcementIds })); } @@ -43,11 +53,21 @@ export function listAdminAnnouncements(params: AdminAnnouncementListParams = {}) return request<{ announcements: SystemAnnouncement[]; total: number; page: number; limit: number }>(api.get("/admin/announcements", { params })); } -export function createAdminAnnouncement(input: { title: string; content: string; level: AnnouncementLevel; pinned: boolean }) { +export function uploadAdminAnnouncementImage(file: File) { + const formData = new FormData(); + formData.append("file", file, file.name); + return request<{ resource: RemoteResource }>(api.post("/admin/announcement-images", formData)); +} + +export function discardAdminAnnouncementImage(id: string) { + return request<{ ok: boolean }>(api.delete(`/admin/announcement-images/${encodeURIComponent(id)}`)); +} + +export function createAdminAnnouncement(input: { title: string; content: string; imageResourceId?: string; level: AnnouncementLevel; pinned: boolean }) { return request<{ announcement: SystemAnnouncement }>(api.post("/admin/announcements", input)); } -export function updateAdminAnnouncement(id: string, input: { title: string; content: string; level: AnnouncementLevel; pinned: boolean }) { +export function updateAdminAnnouncement(id: string, input: { title: string; content: string; imageResourceId?: string; level: AnnouncementLevel; pinned: boolean }) { return request<{ announcement: SystemAnnouncement }>(api.patch(`/admin/announcements/${encodeURIComponent(id)}`, input)); }