feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
504
backend/service/library.go
Normal file
504
backend/service/library.go
Normal file
@@ -0,0 +1,504 @@
|
||||
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(§ions).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"))
|
||||
}
|
||||
Reference in New Issue
Block a user