新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
311 lines
8.5 KiB
Go
311 lines
8.5 KiB
Go
package service
|
||
|
||
import (
|
||
"errors"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
var (
|
||
sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||
ErrSitePageNotDeleted = errors.New("仅已删除的单页可彻底删除")
|
||
)
|
||
|
||
// 写入时显式列出字段(须为独立 Select 参数;逗号拼成一串会被 GORM 当成单个非法字段名)
|
||
var sitePageWriteFields = []string{
|
||
"Slug", "Title", "Content", "Excerpt",
|
||
"Published", "ShowInSidebar", "SortOrder", "AllowComments",
|
||
}
|
||
|
||
// SitePageService 站点单页服务
|
||
type SitePageService struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
func NewSitePageService(db *gorm.DB) *SitePageService {
|
||
return &SitePageService{db: db}
|
||
}
|
||
|
||
// SitePageInput 创建/更新单页入参
|
||
type SitePageInput struct {
|
||
Slug string `json:"slug"`
|
||
Title string `json:"title"`
|
||
Content string `json:"content"`
|
||
Excerpt string `json:"excerpt"`
|
||
Published *bool `json:"published"`
|
||
ShowInSidebar *bool `json:"show_in_sidebar"`
|
||
SortOrder *int `json:"sort_order"`
|
||
AllowComments *bool `json:"allow_comments"`
|
||
}
|
||
|
||
// SidebarPageItem 首页右栏「关于本站」入口
|
||
type SidebarPageItem struct {
|
||
ID uint `json:"id"`
|
||
Slug string `json:"slug"`
|
||
Title string `json:"title"`
|
||
Excerpt string `json:"excerpt"`
|
||
}
|
||
|
||
// PublishedPageItem 公开单页目录(全部页,含未进侧栏的已发布页)
|
||
type PublishedPageItem struct {
|
||
ID uint `json:"id"`
|
||
Slug string `json:"slug"`
|
||
Title string `json:"title"`
|
||
Excerpt string `json:"excerpt"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
func (in *SitePageInput) normalize(existing *model.SitePage) error {
|
||
in.Slug = strings.ToLower(strings.TrimSpace(in.Slug))
|
||
if in.Slug == "" {
|
||
return errors.New("slug 不能为空")
|
||
}
|
||
if len(in.Slug) > 64 {
|
||
return errors.New("slug 不能超过 64 字符")
|
||
}
|
||
if !sitePageSlugRe.MatchString(in.Slug) {
|
||
return errors.New("slug 仅允许小写字母、数字与连字符")
|
||
}
|
||
|
||
in.Title = strings.TrimSpace(in.Title)
|
||
if in.Title == "" {
|
||
return errors.New("标题不能为空")
|
||
}
|
||
if utf8.RuneCountInString(in.Title) > 200 {
|
||
return errors.New("标题不能超过 200 字")
|
||
}
|
||
|
||
in.Content = strings.TrimSpace(in.Content)
|
||
if in.Content == "" {
|
||
return errors.New("内容不能为空")
|
||
}
|
||
if utf8.RuneCountInString(in.Content) > 20000 {
|
||
return errors.New("内容不能超过 20000 字")
|
||
}
|
||
if err := rejectHideBlocks(in.Content, ErrSitePageHideNotAllowed); err != nil {
|
||
return err
|
||
}
|
||
|
||
in.Excerpt = strings.TrimSpace(in.Excerpt)
|
||
if utf8.RuneCountInString(in.Excerpt) > 300 {
|
||
return errors.New("摘要不能超过 300 字")
|
||
}
|
||
_ = existing
|
||
return nil
|
||
}
|
||
|
||
func (in *SitePageInput) applyTo(p *model.SitePage) {
|
||
p.Slug = in.Slug
|
||
p.Title = in.Title
|
||
p.Content = in.Content
|
||
p.Excerpt = in.Excerpt
|
||
if in.Published != nil {
|
||
p.Published = *in.Published
|
||
}
|
||
if in.ShowInSidebar != nil {
|
||
p.ShowInSidebar = *in.ShowInSidebar
|
||
}
|
||
if in.SortOrder != nil {
|
||
p.SortOrder = *in.SortOrder
|
||
}
|
||
if in.AllowComments != nil {
|
||
p.AllowComments = *in.AllowComments
|
||
}
|
||
}
|
||
|
||
// slugTaken 查询 slug 占用情况(Unscoped 含软删行:软删行仍占用数据库唯一索引)
|
||
// active=在用单页占用;deleted=已删除单页占用
|
||
func (s *SitePageService) slugTaken(slug string, excludeID uint) (active, deleted bool, err error) {
|
||
q := s.db.Unscoped().Model(&model.SitePage{}).Where("slug = ?", slug).Select("deleted_at")
|
||
if excludeID > 0 {
|
||
q = q.Where("id <> ?", excludeID)
|
||
}
|
||
var rows []struct {
|
||
DeletedAt gorm.DeletedAt
|
||
}
|
||
if err := q.Find(&rows).Error; err != nil {
|
||
return false, false, err
|
||
}
|
||
for _, r := range rows {
|
||
if r.DeletedAt.Valid {
|
||
deleted = true
|
||
} else {
|
||
active = true
|
||
}
|
||
}
|
||
return active, deleted, nil
|
||
}
|
||
|
||
// ListSidebarPublished 首页右栏:已发布且勾选侧栏展示
|
||
func (s *SitePageService) ListSidebarPublished() ([]SidebarPageItem, error) {
|
||
var pages []model.SitePage
|
||
err := s.db.Where("published = ? AND show_in_sidebar = ?", true, true).
|
||
Order("sort_order ASC, id ASC").
|
||
Select("id", "slug", "title", "excerpt").
|
||
Find(&pages).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]SidebarPageItem, 0, len(pages))
|
||
for _, p := range pages {
|
||
out = append(out, SidebarPageItem{
|
||
ID: p.ID, Slug: p.Slug, Title: p.Title, Excerpt: p.Excerpt,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// ListPublished 已发布单页目录(全部页;含未勾选侧栏的)
|
||
func (s *SitePageService) ListPublished() ([]PublishedPageItem, error) {
|
||
var pages []model.SitePage
|
||
err := s.db.Where("published = ?", true).
|
||
Order("sort_order ASC, id ASC").
|
||
Select("id", "slug", "title", "excerpt", "created_at", "updated_at").
|
||
Limit(100).
|
||
Find(&pages).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]PublishedPageItem, 0, len(pages))
|
||
for _, p := range pages {
|
||
out = append(out, PublishedPageItem{
|
||
ID: p.ID, Slug: p.Slug, Title: p.Title, Excerpt: p.Excerpt,
|
||
CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// GetPublishedBySlug 公开详情(仅已发布)
|
||
func (s *SitePageService) GetPublishedBySlug(slug string) (*model.SitePage, error) {
|
||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||
var p model.SitePage
|
||
err := s.db.Where("slug = ? AND published = ?", slug, true).First(&p).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &p, nil
|
||
}
|
||
|
||
// SitePageAdminItem 管理端单页(附 deleted 标记;DeletedAt json:"-" 不出模型)
|
||
type SitePageAdminItem struct {
|
||
model.SitePage
|
||
Deleted bool `json:"deleted"`
|
||
}
|
||
|
||
// ListAll 管理后台全部单页(含已软删,仅供查看与彻底删除)
|
||
func (s *SitePageService) ListAll() ([]SitePageAdminItem, error) {
|
||
var pages []model.SitePage
|
||
if err := s.db.Unscoped().Order("sort_order ASC, id ASC").Limit(100).Find(&pages).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]SitePageAdminItem, 0, len(pages))
|
||
for _, p := range pages {
|
||
out = append(out, SitePageAdminItem{SitePage: p, Deleted: p.DeletedAt.Valid})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// Get 管理后台按 ID 取单页
|
||
func (s *SitePageService) Get(id uint) (*model.SitePage, error) {
|
||
var p model.SitePage
|
||
if err := s.db.First(&p, id).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return &p, nil
|
||
}
|
||
|
||
// Create 新建单页
|
||
func (s *SitePageService) Create(in *SitePageInput) (*model.SitePage, error) {
|
||
if err := in.normalize(nil); err != nil {
|
||
return nil, err
|
||
}
|
||
active, deleted, err := s.slugTaken(in.Slug, 0)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if active {
|
||
return nil, errors.New("slug 已被占用")
|
||
}
|
||
if deleted {
|
||
return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用")
|
||
}
|
||
p := &model.SitePage{
|
||
Published: false,
|
||
ShowInSidebar: false,
|
||
AllowComments: false,
|
||
SortOrder: 0,
|
||
}
|
||
in.applyTo(p)
|
||
if err := s.db.Select(sitePageWriteFields).Create(p).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// Update 更新单页
|
||
func (s *SitePageService) Update(id uint, in *SitePageInput) (*model.SitePage, error) {
|
||
var p model.SitePage
|
||
if err := s.db.First(&p, id).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if err := in.normalize(&p); err != nil {
|
||
return nil, err
|
||
}
|
||
active, deleted, err := s.slugTaken(in.Slug, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if active {
|
||
return nil, errors.New("slug 已被占用")
|
||
}
|
||
if deleted {
|
||
return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用")
|
||
}
|
||
in.applyTo(&p)
|
||
// 用 map 更新,确保 false / 空串也会写入;勿用逗号拼接的 Select+Save(只会改 updated_at)
|
||
if err := s.db.Model(&model.SitePage{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
|
||
"slug": p.Slug,
|
||
"title": p.Title,
|
||
"content": p.Content,
|
||
"excerpt": p.Excerpt,
|
||
"published": p.Published,
|
||
"show_in_sidebar": p.ShowInSidebar,
|
||
"sort_order": p.SortOrder,
|
||
"allow_comments": p.AllowComments,
|
||
}).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return &p, nil
|
||
}
|
||
|
||
// Delete 软删除单页
|
||
func (s *SitePageService) Delete(id uint) error {
|
||
result := s.db.Delete(&model.SitePage{}, id)
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return gorm.ErrRecordNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Purge 彻底删除已软删的单页(slug 释放可复用)
|
||
func (s *SitePageService) Purge(id uint) error {
|
||
var p model.SitePage
|
||
if err := s.db.Unscoped().First(&p, id).Error; err != nil {
|
||
return gorm.ErrRecordNotFound
|
||
}
|
||
if !p.DeletedAt.Valid {
|
||
return ErrSitePageNotDeleted
|
||
}
|
||
return s.db.Unscoped().Delete(&p).Error
|
||
}
|