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:
@@ -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"),
|
||||
|
||||
@@ -49,6 +49,7 @@ func Models() []any {
|
||||
&model.UserSkillState{},
|
||||
&model.Resource{},
|
||||
&model.ResourceDeletionJob{},
|
||||
&model.AnnouncementImageDraft{},
|
||||
&model.Asset{},
|
||||
&model.ProjectAssetLink{},
|
||||
&model.ProjectAssetFolder{},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
158
backend/internal/repository/announcement_image.go
Normal file
158
backend/internal/repository/announcement_image.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -53,10 +53,11 @@ description: 影策后端核心持久化表与状态边界
|
||||
|
||||
| 表 | 用途 | 关键边界 |
|
||||
| --- | --- | --- |
|
||||
| `announcements` | 保存管理员发布的系统公告 | `pinned` 只改变活动公告和管理列表的排序;公告正文、状态和重新发布时间仍由 service 校验 |
|
||||
| `announcements` | 保存管理员发布的系统公告 | `pinned` 只改变排序;`image_resource_id` 只能绑定由当前管理员上传并在发布事务中消费的公告配图草稿 |
|
||||
| `announcement_image_drafts` | 标记尚未发布的公告配图资源 | 发布/更新事务消费草稿;取消编辑显式回收,超过 24 小时的异常遗留草稿由后台清理 |
|
||||
| `user_announcement_reads` | 保存用户已读状态 | 同一用户和公告唯一;公告重新发布时清除旧已读记录 |
|
||||
|
||||
活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。
|
||||
活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。替换或移除配图时,公告更新、旧资源记录删除和物理删除任务入队在同一数据库事务完成。
|
||||
|
||||
## 资源存储
|
||||
|
||||
|
||||
@@ -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
|
||||
<span className="inline-flex items-center gap-1 text-[var(--fs-label)] font-medium text-foreground/45"><Icon className="size-3" />{meta.label}</span>
|
||||
{announcement.pinned ? <span className="inline-flex items-center gap-1 text-[var(--fs-label)] font-medium text-amber-500"><Pin className="size-3" />置顶</span> : null}
|
||||
</div>
|
||||
{announcement.imageUrl ? <AntImage src={announcementImageUrl(announcement)} alt={`${announcement.title} 配图`} preview className="mt-3 max-h-36 w-56 max-w-full rounded-lg border border-border/70 bg-muted/20 object-contain p-1" /> : null}
|
||||
<AnnouncementContent content={announcement.content} className="mt-1 text-sm leading-6 text-foreground/75 sm:text-[var(--fs-body-lg)]" />
|
||||
<time dateTime={announcement.publishedAt} className="mt-2 block text-xs tabular-nums text-foreground/40">{relativeTime(announcement.publishedAt)} · {formatDateTime(announcement.publishedAt)}</time>
|
||||
</div>
|
||||
|
||||
@@ -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<SystemAnnouncement | null>(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<HTMLInputElement | null>(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<HTMLInputElement>) => {
|
||||
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) => (
|
||||
<div className="min-w-0 py-0.5">
|
||||
<div className="truncate text-sm font-medium text-foreground" title={announcement.title}>{announcement.title}</div>
|
||||
<div className="mt-1 line-clamp-2 whitespace-pre-wrap text-xs leading-5 text-foreground/50">{announcement.content}</div>
|
||||
<div className="flex min-w-0 items-center gap-3 py-0.5">
|
||||
{announcement.imageUrl ? <img src={announcementImageUrl(announcement)} alt="" loading="lazy" decoding="async" className="size-14 shrink-0 rounded-md border border-border/70 bg-muted/20 object-contain p-0.5" /> : null}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-foreground" title={announcement.title}>{announcement.title}</div>
|
||||
<div className="mt-1 line-clamp-2 whitespace-pre-wrap text-xs leading-5 text-foreground/50">{announcement.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -181,7 +259,7 @@ export default function AdminAnnouncementsPanel() {
|
||||
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />}
|
||||
/>
|
||||
|
||||
<Modal title={editingAnnouncement ? "编辑并重新发布公告" : "发布系统公告"} open={modalOpen} width={760} centered okText={editingAnnouncement ? "保存并重新发布" : "立即发布"} cancelText="取消" confirmLoading={publishing} onOk={() => void publish()} onCancel={() => { setModalOpen(false); setEditingAnnouncement(null); }} destroyOnHidden>
|
||||
<Modal title={editingAnnouncement ? "编辑并重新发布公告" : "发布系统公告"} open={modalOpen} width={760} centered okText={editingAnnouncement ? "保存并重新发布" : "立即发布"} cancelText="取消" confirmLoading={publishing} okButtonProps={{ disabled: imageUploading }} cancelButtonProps={{ disabled: imageUploading }} onOk={() => void publish()} onCancel={() => void closeEditor()} destroyOnHidden>
|
||||
<Form form={form} layout="vertical" className="pt-3" requiredMark={false}>
|
||||
<Form.Item name="title" label="公告标题" rules={[{ required: true, whitespace: true, message: "请填写公告标题" }, { max: 120, message: "标题不能超过 120 个字符" }]}>
|
||||
<Input maxLength={120} showCount placeholder="例如:视频模型已恢复正常使用" />
|
||||
@@ -192,6 +270,21 @@ export default function AdminAnnouncementsPanel() {
|
||||
<Form.Item name="pinned" label="展示方式" valuePropName="checked" extra="置顶公告会优先展示。">
|
||||
<Switch checkedChildren="置顶" unCheckedChildren="普通" />
|
||||
</Form.Item>
|
||||
<Form.Item name="imageResourceId" hidden><Input /></Form.Item>
|
||||
<Form.Item label="公告配图" extra="可选,真实图片文件不超过 10MB;取消、替换或移除时会回收未发布草稿。">
|
||||
<div className="space-y-2">
|
||||
{imagePreviewUrl ? (
|
||||
<div className="relative flex min-h-28 items-center justify-center overflow-hidden rounded-md border border-border/70 bg-muted/20 p-2">
|
||||
<img src={imagePreviewUrl} alt="公告配图预览" className="max-h-44 w-full object-contain" />
|
||||
<Button type="text" size="small" danger disabled={imageUploading} icon={<X className="size-3.5" />} className="!absolute right-1 top-1 !size-7 !min-w-7 !p-0" onClick={() => void clearAnnouncementImage()} aria-label="移除公告配图" title="移除公告配图" />
|
||||
</div>
|
||||
) : <div className="flex min-h-24 items-center justify-center rounded-md border border-dashed border-border/80 bg-muted/10 text-xs text-foreground/45">暂未添加配图</div>}
|
||||
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => void uploadAnnouncementImage(event)} />
|
||||
<Button icon={<Upload className="size-3.5" />} loading={imageUploading} onClick={() => imageInputRef.current?.click()}>
|
||||
{imagePreviewUrl ? "更换配图" : "上传配图"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="公告正文" rules={[{ required: true, whitespace: true, message: "请填写公告正文" }, { max: 4000, message: "正文不能超过 4000 个字符" }]}>
|
||||
<Input.TextArea maxLength={4000} showCount autoSize={{ minRows: 6, maxRows: 12 }} placeholder="填写服务状态、影响范围和用户需要采取的操作" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -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<AnnouncementFeed>(api.get("/announcements"));
|
||||
}
|
||||
|
||||
export function announcementImageUrl(announcement: Pick<SystemAnnouncement, "imageUrl">) {
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user