Files
jiang13-bbs/backend/service/announcement.go
freefire a83276060f feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 19:52:06 +08:00

207 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"errors"
"strings"
"github.com/freefire/jiang13-bbs/markdown"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 公告标签允许使用的预设色(服务端白名单,禁止透传任意颜色值)
var allowedTagColors = map[string]bool{
"blue": true, "green": true, "orange": true,
"red": true, "purple": true, "gray": true,
"teal": true, "cyan": true, "pink": true,
"amber": true, "indigo": true, "rose": true,
}
// AnnouncementService 站点公告服务
type AnnouncementService struct {
db *gorm.DB
}
func NewAnnouncementService(db *gorm.DB) *AnnouncementService {
return &AnnouncementService{db: db}
}
// AnnouncementInput 创建/更新公告的入参
type AnnouncementInput struct {
Title string `json:"title"`
Content string `json:"content"`
Tag string `json:"tag"`
TagColor string `json:"tag_color"`
Published *bool `json:"published"`
Pinned *bool `json:"pinned"`
}
// normalize 清洗并校验入参,返回可直接落库的字段
func (in *AnnouncementInput) normalize(existing *model.Announcement) error {
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" {
return errors.New("标题不能为空")
}
if len([]rune(in.Title)) > 200 {
return errors.New("标题不能超过 200 字")
}
in.Content = strings.TrimSpace(in.Content)
if in.Content == "" {
return errors.New("内容不能为空")
}
if err := markdown.ValidateHideContent(in.Content); err != nil {
return err
}
if err := rejectReplyHideBlocks(in.Content, ErrAnnouncementReplyHideNotAllowed); err != nil {
return err
}
in.Tag = strings.TrimSpace(in.Tag)
if in.Tag == "" {
in.Tag = "公告"
}
if len([]rune(in.Tag)) > 32 {
return errors.New("标签不能超过 32 字")
}
in.TagColor = strings.ToLower(strings.TrimSpace(in.TagColor))
if in.TagColor == "" {
if existing != nil && existing.TagColor != "" {
in.TagColor = existing.TagColor
} else {
in.TagColor = "blue"
}
}
if !allowedTagColors[in.TagColor] {
return errors.New("不支持的标签颜色")
}
return nil
}
// applyTo 把入参写入模型(Published/Pinned 为指针以区分"未传"与"显式 false")
func (in *AnnouncementInput) applyTo(a *model.Announcement) {
a.Title = in.Title
a.Content = in.Content
a.Tag = in.Tag
a.TagColor = in.TagColor
if in.Published != nil {
a.Published = *in.Published
}
if in.Pinned != nil {
a.Pinned = *in.Pinned
}
}
// 写入字段须为独立 Select 参数;逗号拼成一串会被 GORM 当成单个非法字段名
var announcementWriteFields = []string{
"Title", "Content", "Tag", "TagColor", "Published", "Pinned",
}
// ListPublished 已发布公告(首页右栏),置顶优先再按发布时间倒序
func (s *AnnouncementService) ListPublished(limit int) ([]model.Announcement, error) {
if limit <= 0 {
limit = 5
}
var list []model.Announcement
err := s.db.Where("published = ?", true).
Order("pinned DESC, created_at DESC, id DESC").Limit(limit).Find(&list).Error
return list, err
}
// ListPublishedPage 已发布公告分页(全部公告页)
func (s *AnnouncementService) ListPublishedPage(page, size int) ([]model.Announcement, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
q := s.db.Model(&model.Announcement{}).Where("published = ?", true)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.Announcement
err := q.Order("pinned DESC, created_at DESC, id DESC").
Offset((page - 1) * size).Limit(size).Find(&list).Error
return list, total, err
}
// GetPublished 取单条已发布公告(详情页);草稿/不存在返回 gorm.ErrRecordNotFound
func (s *AnnouncementService) GetPublished(id uint) (*model.Announcement, error) {
var a model.Announcement
err := s.db.Where("published = ?", true).First(&a, id).Error
if err != nil {
return nil, err
}
return &a, nil
}
// ListAll 管理后台:含草稿,置顶优先
func (s *AnnouncementService) ListAll() ([]model.Announcement, error) {
var list []model.Announcement
err := s.db.Order("pinned DESC, created_at DESC, id DESC").Limit(100).Find(&list).Error
return list, err
}
// Create 创建公告
func (s *AnnouncementService) Create(in *AnnouncementInput) (*model.Announcement, error) {
if err := in.normalize(nil); err != nil {
return nil, err
}
a := &model.Announcement{Published: true, Pinned: false}
in.applyTo(a)
if err := s.db.Select(announcementWriteFields).Create(a).Error; err != nil {
return nil, err
}
return a, nil
}
// Update 更新公告
func (s *AnnouncementService) Update(id uint, in *AnnouncementInput) (*model.Announcement, error) {
var a model.Announcement
if err := s.db.First(&a, id).Error; err != nil {
return nil, err
}
if err := in.normalize(&a); err != nil {
return nil, err
}
in.applyTo(&a)
if err := s.db.Model(&model.Announcement{}).Where("id = ?", a.ID).Updates(map[string]interface{}{
"title": a.Title,
"content": a.Content,
"tag": a.Tag,
"tag_color": a.TagColor,
"published": a.Published,
"pinned": a.Pinned,
}).Error; err != nil {
return nil, err
}
return &a, nil
}
// TogglePin 切换置顶状态
func (s *AnnouncementService) TogglePin(id uint) (*model.Announcement, error) {
var a model.Announcement
if err := s.db.First(&a, id).Error; err != nil {
return nil, err
}
a.Pinned = !a.Pinned
if err := s.db.Model(&a).Select("Pinned").Update("pinned", a.Pinned).Error; err != nil {
return nil, err
}
return &a, nil
}
// Delete 删除公告(软删除)
func (s *AnnouncementService) Delete(id uint) error {
result := s.db.Delete(&model.Announcement{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}