feat(admin): 系统公告 - 支持配图草稿与自动回收

Adapted from ddcat-ai/open-ai-canvas#339

Original commit: fcdbe2aa833f31f683f709b7f409a0534ed78e20

Refs #339

Co-authored-by: 宇熙 <101980758+yuxiwl@users.noreply.github.com>
This commit is contained in:
ddCat
2026-08-29 15:16:18 +08:00
parent 791621667e
commit 6eadaa00a1
13 changed files with 811 additions and 70 deletions

View File

@@ -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"),

View File

@@ -49,6 +49,7 @@ func Models() []any {
&model.UserSkillState{},
&model.Resource{},
&model.ResourceDeletionJob{},
&model.AnnouncementImageDraft{},
&model.Asset{},
&model.ProjectAssetLink{},
&model.ProjectAssetFolder{},

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()
}
}
}()
}