feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

View File

@@ -1,10 +1,12 @@
package service
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
@@ -44,7 +46,16 @@ func LibraryExtAllowed(ext string) bool {
// 写入时显式列出字段(独立 Select 参数,同 sitePageWriteFields)
var libraryDocWriteFields = []string{
"Slug", "Title", "Description", "CoverURL", "Published", "SortOrder", "EntriesAuto",
"Slug", "Title", "Description", "CoverURL", "CoverWidth", "CoverHeight",
"Published", "SortOrder", "EntriesAuto", "Author", "CreatorID",
}
// LibraryCreator 条目创建者摘要(仅列表/详情展示所需字段;用户已注销时为 nil)
type LibraryCreator struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
// LibraryDocDetail 条目 + 文件列表 + 章节树(管理端与公开详情共用)
@@ -53,27 +64,34 @@ type LibraryDocDetail struct {
Deleted bool `json:"deleted"` // 软删标记(DeletedAt json:"-" 不出模型)
Files []model.LibraryFile `json:"files"`
Sections []model.LibrarySection `json:"sections"`
Creator *LibraryCreator `json:"creator,omitempty"` // 创建者摘要(0/已注销=nil)
}
// LibraryDocListItem 公开目录条目(不含 description 全文,含文件概要)
// 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"`
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"`
Description string `json:"description"`
CoverURL string `json:"cover_url"`
SortOrder int `json:"sort_order"`
FileCount int `json:"file_count"`
Files []model.LibraryFile `json:"files"`
Creator *LibraryCreator `json:"creator,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// LibraryInput 创建/更新书库条目入参
type LibraryInput struct {
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"` // 书籍作者(自由文本,可空)
Description string `json:"description"`
CoverURL string `json:"cover_url"`
CoverWidth int `json:"cover_width"` // 封面自然宽(0=未知)
CoverHeight int `json:"cover_height"` // 封面自然高(0=未知)
Published *bool `json:"published"`
SortOrder *int `json:"sort_order"`
EntriesAuto *bool `json:"entries_auto"` // 全章节条目卡模式(nil=不修改)
@@ -99,6 +117,11 @@ func (in *LibraryInput) normalize() error {
return errors.New("标题不能超过 200 字")
}
in.Author = strings.TrimSpace(in.Author)
if utf8.RuneCountInString(in.Author) > 100 {
return errors.New("作者不能超过 100 字")
}
in.Description = strings.TrimSpace(in.Description)
if utf8.RuneCountInString(in.Description) > libraryDescriptionMax {
return errors.New("介绍不能超过 20000 字")
@@ -115,8 +138,17 @@ func (in *LibraryInput) normalize() error {
func (in *LibraryInput) applyTo(d *model.LibraryDoc) {
d.Slug = in.Slug
d.Title = in.Title
d.Author = in.Author
d.Description = in.Description
d.CoverURL = in.CoverURL
// 封面清空时一并清除尺寸;有封面但未传尺寸时保留 0(前端回退运行时探测)
if in.CoverURL == "" {
d.CoverWidth = 0
d.CoverHeight = 0
} else {
d.CoverWidth = in.CoverWidth
d.CoverHeight = in.CoverHeight
}
if in.Published != nil {
d.Published = *in.Published
}
@@ -130,9 +162,10 @@ func (in *LibraryInput) applyTo(d *model.LibraryDoc) {
// LibraryService 书库服务(条目 CRUD + 文件落盘/下载/预览)
type LibraryService struct {
db *gorm.DB
dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html)
setting *SettingService
db *gorm.DB
dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html)
uploadsDir string // data/uploads:封面回填时解析本地封面图
setting *SettingService
}
func NewLibraryService(db *gorm.DB, dataDir string) *LibraryService {
@@ -145,6 +178,12 @@ func (s *LibraryService) WithSetting(setting *SettingService) *LibraryService {
return s
}
// WithUploadsDir 注入上传根目录(封面回填用:把 /uploads/... 封面 URL 解析为本地文件)
func (s *LibraryService) WithUploadsDir(uploadsDir string) *LibraryService {
s.uploadsDir = uploadsDir
return s
}
func (s *LibraryService) EnsureDir() error {
return os.MkdirAll(s.dir, 0o755)
}
@@ -224,6 +263,7 @@ func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail
for _, sec := range sections {
secByDoc[sec.DocID] = append(secByDoc[sec.DocID], sec)
}
creatorByID := s.creatorMap(docs)
for _, d := range docs {
fs := byDoc[d.ID]
if fs == nil {
@@ -233,12 +273,40 @@ func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail
if ss == nil {
ss = []model.LibrarySection{}
}
out = append(out, LibraryDocDetail{LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss})
out = append(out, LibraryDocDetail{
LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss,
Creator: creatorByID[d.CreatorID],
})
}
return out
}
func (s *LibraryService) Create(in *LibraryInput) (*model.LibraryDoc, error) {
// creatorMap 批量取条目创建者摘要(一条 SQL,避免列表 N+1;已注销用户查不到即缺省)
func (s *LibraryService) creatorMap(docs []model.LibraryDoc) map[uint]*LibraryCreator {
ids := make([]uint, 0, len(docs))
seen := map[uint]bool{}
for _, d := range docs {
if d.CreatorID > 0 && !seen[d.CreatorID] {
seen[d.CreatorID] = true
ids = append(ids, d.CreatorID)
}
}
if len(ids) == 0 {
return map[uint]*LibraryCreator{}
}
var users []model.User
if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", ids).Find(&users).Error; err != nil {
return map[uint]*LibraryCreator{}
}
m := make(map[uint]*LibraryCreator, len(users))
for i := range users {
u := &users[i]
m[u.ID] = &LibraryCreator{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar}
}
return m
}
func (s *LibraryService) Create(in *LibraryInput, creatorID ...uint) (*model.LibraryDoc, error) {
if err := in.normalize(); err != nil {
return nil, err
}
@@ -253,6 +321,9 @@ func (s *LibraryService) Create(in *LibraryInput) (*model.LibraryDoc, error) {
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
}
d := &model.LibraryDoc{Published: false, SortOrder: 0}
if len(creatorID) > 0 {
d.CreatorID = creatorID[0]
}
in.applyTo(d)
if err := s.db.Select(libraryDocWriteFields).Create(d).Error; err != nil {
return nil, err
@@ -279,12 +350,15 @@ func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, e
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
}
in.applyTo(&d)
// map 更新确保 false/空串写入(同 SitePage.Update)
// map 更新确保 false/空串写入(同 SitePage.Update);creator_id 创建后不变,不在此列
if err := s.db.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(map[string]interface{}{
"slug": d.Slug,
"title": d.Title,
"author": d.Author,
"description": d.Description,
"cover_url": d.CoverURL,
"cover_width": d.CoverWidth,
"cover_height": d.CoverHeight,
"published": d.Published,
"sort_order": d.SortOrder,
"entries_auto": d.EntriesAuto,
@@ -294,6 +368,77 @@ func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, e
return &d, nil
}
// resolveCoverPath 把 /uploads/... 封面 URL 解析为本地绝对路径;外链返回空串
func (s *LibraryService) resolveCoverPath(coverURL string) (string, bool) {
rel := strings.TrimPrefix(strings.TrimSpace(coverURL), "/uploads/")
if rel == "" || rel == coverURL || strings.Contains(rel, "..") {
return "", false
}
abs := filepath.Join(s.uploadsDir, filepath.FromSlash(rel))
root := filepath.Clean(s.uploadsDir)
clean := filepath.Clean(abs)
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
return "", false
}
return clean, true
}
// measureCoverReader 从图片流读取自然宽高(复用上传层的格式探测与尺寸解码)
func measureCoverReader(r io.Reader) (w, h int, err error) {
data, err := io.ReadAll(r)
if err != nil {
return 0, 0, err
}
format, err := detectImageFormat(data)
if err != nil {
return 0, 0, err
}
return decodeImageSizeReader(bytes.NewReader(data), format.mime)
}
// ensureCoverDimensions 若条目有封面但未存自然宽高,则测量并写回库(幂等:已有尺寸直接跳过)。
// 用于读路径懒回填,避免独立运维接口:首次访问后尺寸即落库,后续 SSR 首帧直接判定横竖版。
func (s *LibraryService) ensureCoverDimensions(d *model.LibraryDoc) {
if d.CoverURL == "" || (d.CoverWidth > 0 && d.CoverHeight > 0) || s.uploadsDir == "" {
return
}
url := strings.TrimSpace(d.CoverURL)
var reader io.ReadCloser
switch {
case strings.HasPrefix(url, "/uploads/"):
abs, ok := s.resolveCoverPath(url)
if !ok {
return
}
f, err := os.Open(abs)
if err != nil {
return
}
reader = f
case strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://"):
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return
}
reader = resp.Body
default:
return
}
w, h, err := measureCoverReader(reader)
reader.Close()
if err != nil || w <= 0 || h <= 0 {
return
}
if err := s.db.Model(d).Updates(map[string]interface{}{
"cover_width": w,
"cover_height": h,
}).Error; err != nil {
return
}
d.CoverWidth, d.CoverHeight = w, h
}
func (s *LibraryService) Delete(id uint) error {
result := s.db.Delete(&model.LibraryDoc{}, id)
if result.Error != nil {
@@ -349,8 +494,9 @@ func (s *LibraryService) ListPublished() ([]LibraryDocListItem, error) {
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,
ID: d.ID, Slug: d.Slug, Title: d.Title, Author: d.Author, Description: d.Description,
CoverURL: d.CoverURL, SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files,
Creator: d.Creator,
CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt,
})
}
@@ -364,6 +510,8 @@ func (s *LibraryService) GetPublishedBySlug(slug string) (*LibraryDocDetail, err
if err := s.db.Where("slug = ? AND published = ?", slug, true).First(&d).Error; err != nil {
return nil, ErrLibraryNotFound
}
// 懒回填:有封面但未存尺寸时当场测量并写回(首次访问后即稳定,消除横竖版探测闪烁)
s.ensureCoverDimensions(&d)
out := s.attachFiles([]model.LibraryDoc{d})
return &out[0], nil
}