Files
jiang13-bbs/backend/service/library.go
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

505 lines
14 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 (
"crypto/rand"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 书库文件扩展名白名单(小写、不含点);附件仅提供下载分发,
// 书籍正文走章节阅读,不做在线预览
var libraryAllowedExts = map[string]struct{}{
"html": {}, "htm": {}, "pdf": {}, "epub": {}, "mobi": {}, "azw3": {},
"zip": {}, "7z": {}, "rar": {}, "txt": {}, "md": {},
"doc": {}, "docx": {}, "ppt": {}, "pptx": {},
}
const (
MaxLibraryFilesPerDoc = 20
libraryDescriptionMax = 20000
)
var (
ErrLibraryNotFound = errors.New("书库条目不存在")
ErrLibraryExtDenied = errors.New("不支持该文档格式")
ErrLibraryTooMany = errors.New("文件数量超过上限")
ErrLibraryNotDeleted = errors.New("仅已删除的条目可彻底删除")
)
// LibraryExtAllowed 上传扩展名是否在书库白名单内
func LibraryExtAllowed(ext string) bool {
_, ok := libraryAllowedExts[strings.ToLower(strings.TrimPrefix(ext, "."))]
return ok
}
// 写入时显式列出字段(独立 Select 参数,同 sitePageWriteFields)
var libraryDocWriteFields = []string{
"Slug", "Title", "Description", "CoverURL", "Published", "SortOrder", "EntriesAuto",
}
// LibraryDocDetail 条目 + 文件列表 + 章节树(管理端与公开详情共用)
type LibraryDocDetail struct {
model.LibraryDoc
Deleted bool `json:"deleted"` // 软删标记(DeletedAt json:"-" 不出模型)
Files []model.LibraryFile `json:"files"`
Sections []model.LibrarySection `json:"sections"`
}
// LibraryDocListItem 公开目录条目(不含 description 全文,含文件概要)
type LibraryDocListItem struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
CoverURL string `json:"cover_url"`
SortOrder int `json:"sort_order"`
FileCount int `json:"file_count"`
Files []model.LibraryFile `json:"files"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// LibraryInput 创建/更新书库条目入参
type LibraryInput struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
CoverURL string `json:"cover_url"`
Published *bool `json:"published"`
SortOrder *int `json:"sort_order"`
EntriesAuto *bool `json:"entries_auto"` // 全章节条目卡模式(nil=不修改)
}
func (in *LibraryInput) normalize() 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.Description = strings.TrimSpace(in.Description)
if utf8.RuneCountInString(in.Description) > libraryDescriptionMax {
return errors.New("介绍不能超过 20000 字")
}
in.CoverURL = strings.TrimSpace(in.CoverURL)
if utf8.RuneCountInString(in.CoverURL) > 512 {
return errors.New("封面地址过长")
}
return nil
}
func (in *LibraryInput) applyTo(d *model.LibraryDoc) {
d.Slug = in.Slug
d.Title = in.Title
d.Description = in.Description
d.CoverURL = in.CoverURL
if in.Published != nil {
d.Published = *in.Published
}
if in.SortOrder != nil {
d.SortOrder = *in.SortOrder
}
if in.EntriesAuto != nil {
d.EntriesAuto = *in.EntriesAuto
}
}
// LibraryService 书库服务(条目 CRUD + 文件落盘/下载/预览)
type LibraryService struct {
db *gorm.DB
dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html)
setting *SettingService
}
func NewLibraryService(db *gorm.DB, dataDir string) *LibraryService {
return &LibraryService{db: db, dir: filepath.Join(dataDir, "library")}
}
// WithSetting 注入站点设置(文件大小上限复用附件设置)
func (s *LibraryService) WithSetting(setting *SettingService) *LibraryService {
s.setting = setting
return s
}
func (s *LibraryService) EnsureDir() error {
return os.MkdirAll(s.dir, 0o755)
}
func (s *LibraryService) FilePath(f *model.LibraryFile) string {
return filepath.Join(s.dir, f.StoredName)
}
// slugTaken 查询 slug 占用情况(Unscoped 含软删行:软删行仍占用数据库唯一索引)
// active=在用条目占用;deleted=已删除条目占用
func (s *LibraryService) slugTaken(slug string, excludeID uint) (active, deleted bool, err error) {
q := s.db.Unscoped().Model(&model.LibraryDoc{}).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
}
// ---------- 条目:管理端 ----------
// ListAll 管理后台全部条目(含未发布与已软删,带文件;已删条目仅供查看与彻底删除)
func (s *LibraryService) ListAll() ([]LibraryDocDetail, error) {
var docs []model.LibraryDoc
if err := s.db.Unscoped().Order("sort_order ASC, id ASC").Limit(200).Find(&docs).Error; err != nil {
return nil, err
}
return s.attachFiles(docs), nil
}
// Get 管理后台按 ID 取条目
func (s *LibraryService) Get(id uint) (*LibraryDocDetail, error) {
var d model.LibraryDoc
if err := s.db.First(&d, id).Error; err != nil {
return nil, ErrLibraryNotFound
}
out := s.attachFiles([]model.LibraryDoc{d})
return &out[0], nil
}
func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail {
out := make([]LibraryDocDetail, 0, len(docs))
if len(docs) == 0 {
return out
}
ids := make([]uint, 0, len(docs))
for _, d := range docs {
ids = append(ids, d.ID)
}
var files []model.LibraryFile
if err := s.db.Where("doc_id IN ?", ids).
Order("sort_order ASC, id ASC").Find(&files).Error; err != nil {
files = nil
}
byDoc := make(map[uint][]model.LibraryFile, len(docs))
for _, f := range files {
byDoc[f.DocID] = append(byDoc[f.DocID], f)
}
var sections []model.LibrarySection
if err := s.db.Where("doc_id IN ?", ids).
Order("sort_order ASC, id ASC").Find(&sections).Error; err != nil {
sections = nil
}
secByDoc := make(map[uint][]model.LibrarySection, len(docs))
for _, sec := range sections {
secByDoc[sec.DocID] = append(secByDoc[sec.DocID], sec)
}
for _, d := range docs {
fs := byDoc[d.ID]
if fs == nil {
fs = []model.LibraryFile{}
}
ss := secByDoc[d.ID]
if ss == nil {
ss = []model.LibrarySection{}
}
out = append(out, LibraryDocDetail{LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss})
}
return out
}
func (s *LibraryService) Create(in *LibraryInput) (*model.LibraryDoc, error) {
if err := in.normalize(); 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("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
}
d := &model.LibraryDoc{Published: false, SortOrder: 0}
in.applyTo(d)
if err := s.db.Select(libraryDocWriteFields).Create(d).Error; err != nil {
return nil, err
}
return d, nil
}
func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, error) {
var d model.LibraryDoc
if err := s.db.First(&d, id).Error; err != nil {
return nil, ErrLibraryNotFound
}
if err := in.normalize(); 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(&d)
// map 更新确保 false/空串写入(同 SitePage.Update)
if err := s.db.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(map[string]interface{}{
"slug": d.Slug,
"title": d.Title,
"description": d.Description,
"cover_url": d.CoverURL,
"published": d.Published,
"sort_order": d.SortOrder,
"entries_auto": d.EntriesAuto,
}).Error; err != nil {
return nil, err
}
return &d, nil
}
func (s *LibraryService) Delete(id uint) error {
result := s.db.Delete(&model.LibraryDoc{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrLibraryNotFound
}
return nil
}
// Purge 彻底删除已软删的条目(章节/文件行与磁盘文件一并清除,slug 释放可复用)
func (s *LibraryService) Purge(id uint) error {
var d model.LibraryDoc
if err := s.db.Unscoped().First(&d, id).Error; err != nil {
return ErrLibraryNotFound
}
if !d.DeletedAt.Valid {
return ErrLibraryNotDeleted
}
// 先取出文件清单(含此前单独软删的文件行),事务成功后统一删磁盘
var files []model.LibraryFile
if err := s.db.Unscoped().Where("doc_id = ?", id).Find(&files).Error; err != nil {
return err
}
err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Where("doc_id = ?", id).Delete(&model.LibrarySection{}).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("doc_id = ?", id).Delete(&model.LibraryFile{}).Error; err != nil {
return err
}
return tx.Unscoped().Delete(&model.LibraryDoc{}, id).Error
})
if err != nil {
return err
}
// 事务外尽力删除磁盘文件(同 DeleteFile 惯例,失败不影响返回)
for i := range files {
_ = os.Remove(s.FilePath(&files[i]))
}
return nil
}
// ---------- 条目:公开端 ----------
// ListPublished 已发布条目目录(含文件概要,不含 description 全文)
func (s *LibraryService) ListPublished() ([]LibraryDocListItem, error) {
details, err := s.publishedDetails()
if err != nil {
return nil, err
}
out := make([]LibraryDocListItem, 0, len(details))
for _, d := range details {
out = append(out, LibraryDocListItem{
ID: d.ID, Slug: d.Slug, Title: d.Title, CoverURL: d.CoverURL,
SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files,
CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt,
})
}
return out, nil
}
// GetPublishedBySlug 公开详情(仅已发布)
func (s *LibraryService) GetPublishedBySlug(slug string) (*LibraryDocDetail, error) {
slug = strings.ToLower(strings.TrimSpace(slug))
var d model.LibraryDoc
if err := s.db.Where("slug = ? AND published = ?", slug, true).First(&d).Error; err != nil {
return nil, ErrLibraryNotFound
}
out := s.attachFiles([]model.LibraryDoc{d})
return &out[0], nil
}
func (s *LibraryService) publishedDetails() ([]LibraryDocDetail, error) {
var docs []model.LibraryDoc
if err := s.db.Where("published = ?", true).
Order("sort_order ASC, id ASC").Limit(100).Find(&docs).Error; err != nil {
return nil, err
}
return s.attachFiles(docs), nil
}
// ---------- 文件 ----------
func (s *LibraryService) maxBytes() int64 {
if s.setting == nil {
return FileMaxBytes
}
n, err := s.setting.AttachmentMaxBytes()
if err != nil || n < 1 {
return FileMaxBytes
}
return n
}
// AddFile 上传文件到条目(落盘 data/library + 建行)
func (s *LibraryService) AddFile(docID, userID uint, originalName string, src io.Reader) (*model.LibraryFile, error) {
if src == nil {
return nil, errors.New("文件为空")
}
var doc model.LibraryDoc
if err := s.db.First(&doc, docID).Error; err != nil {
return nil, ErrLibraryNotFound
}
name := sanitizeFilename(originalName)
if name == "" {
name = "file"
}
ext := strings.TrimPrefix(ExtOfFilename(name), ".")
if !LibraryExtAllowed(ext) {
return nil, ErrLibraryExtDenied
}
var count int64
if err := s.db.Model(&model.LibraryFile{}).Where("doc_id = ?", docID).Count(&count).Error; err != nil {
return nil, err
}
if count >= MaxLibraryFilesPerDoc {
return nil, ErrLibraryTooMany
}
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
stored := hex.EncodeToString(raw) + "." + ext
full := filepath.Join(s.dir, stored)
f, err := os.OpenFile(full, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return nil, err
}
maxB := s.maxBytes()
written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1))
_ = f.Close()
if copyErr != nil {
_ = os.Remove(full)
return nil, copyErr
}
if written == 0 {
_ = os.Remove(full)
return nil, errors.New("文件为空")
}
if written > maxB {
_ = os.Remove(full)
mb := int(maxB >> 20)
if mb < 1 {
mb = 1
}
return nil, errors.New("文件过大:不能超过 " + strconv.Itoa(mb) + "MB")
}
file := &model.LibraryFile{
DocID: docID,
UserID: userID,
Name: name,
StoredName: stored,
Ext: ext,
MIME: MimeForAttachmentExt("." + ext),
Size: int(written),
}
if err := s.db.Create(file).Error; err != nil {
_ = os.Remove(full)
return nil, err
}
return file, nil
}
// DeleteFile 删除文件(软删行 + 磁盘尽力删除)
func (s *LibraryService) DeleteFile(id uint) error {
var f model.LibraryFile
if err := s.db.First(&f, id).Error; err != nil {
return ErrLibraryNotFound
}
if err := s.db.Delete(&f).Error; err != nil {
return err
}
_ = os.Remove(s.FilePath(&f))
return nil
}
// GetPublicFile 公开取文件(要求所属条目存在且已发布;软删条目的文件不可达)
func (s *LibraryService) GetPublicFile(id uint) (*model.LibraryFile, error) {
var f model.LibraryFile
if err := s.db.First(&f, id).Error; err != nil {
return nil, ErrLibraryNotFound
}
if f.DocID == 0 {
return nil, ErrLibraryNotFound
}
var n int64
if err := s.db.Model(&model.LibraryDoc{}).
Where("id = ? AND published = ?", f.DocID, true).Count(&n).Error; err != nil {
return nil, err
}
if n == 0 {
return nil, ErrLibraryNotFound
}
return &f, nil
}
// IncrDownload 下载计数原子递增
func (s *LibraryService) IncrDownload(id uint) {
s.db.Model(&model.LibraryFile{}).Where("id = ?", id).
UpdateColumn("download_count", gorm.Expr("download_count + 1"))
}