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

@@ -0,0 +1,631 @@
package service
// 书库导入恢复:解析导出 ZIP(见 library_export.go),在本站重建书籍。
//
// - 单本包(根 book.json,format=jiang13-library-book)与全库包(library.json 索引)
// - mode=create(默认):同 slug 在用条目冲突时跳过该书;软删占用同样拒绝(释放后再来)
// - mode=overwrite:覆盖同 slug 在用条目(章节/附件整体替换,元信息按包恢复)
// - 附件复用 AddFile(扩展名白名单/大小/20 个上限/随机落盘),磁盘名重新生成但保留下载计数
// - 内嵌封面按魔数校验后随机名落盘 uploads/images;外链封面仅保留 URL
// - 逐本独立处理:单本书失败不影响包内其他书,结果以报告返回(包本身非法才整体报错)
import (
"archive/zip"
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"path"
"path/filepath"
"strings"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// LibraryImportMaxBytes 导入 ZIP 上传上限(超出由 handler 提前拦截)
const LibraryImportMaxBytes = 512 << 20
const (
LibraryImportModeCreate = "create" // 冲突跳过
LibraryImportModeOverwrite = "overwrite" // 冲突覆盖
)
// 失败原因(前端据此区分冲突,可提示覆盖重导)
const (
libraryImportReasonConflict = "conflict" // slug 被在用条目占用(create 模式)
libraryImportReasonDeleted = "deleted" // slug 被已软删条目占用
libraryImportReasonInvalid = "invalid" // 清单数据非法
libraryImportReasonFailed = "failed" // 落盘/写库失败
)
var (
ErrLibraryImportBadZip = errors.New("无法读取导入文件,请上传书库导出的 ZIP 备份包")
ErrLibraryImportFormat = errors.New("不是有效的书库导出包:缺少清单文件或清单已损坏")
ErrLibraryImportVer = errors.New("导出版本不受支持")
ErrLibraryImportEmpty = errors.New("备份包内没有可导入的书籍")
ErrLibraryImportMode = errors.New("无效的导入模式")
)
// LibraryImportItem 成功导入的单本结果
type LibraryImportItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
Action string `json:"action"` // created / overwritten
Sections int `json:"sections"`
Files int `json:"files"`
}
// LibraryImportFailure 单本书失败明细
type LibraryImportFailure struct {
Slug string `json:"slug"`
Title string `json:"title"`
Reason string `json:"reason"`
Error string `json:"error"`
}
// LibraryImportReport 导入报告
type LibraryImportReport struct {
Mode string `json:"mode"`
Total int `json:"total"`
Imported []LibraryImportItem `json:"imported"`
Failed []LibraryImportFailure `json:"failed"`
}
// bookImportPlan 单本书的导入计划(清单 + ZIP 内目录前缀)
type bookImportPlan struct {
book libraryExportBook
dir string
}
// plannedSection 重建后的章节(保留原始 key 用于两级映射;sort 为同层级序号)
type plannedSection struct {
key string
parentKey string
title string
content string
sort int
}
// ImportLibraryZip 从导出 ZIP 恢复书籍。zipPath 为已落盘的临时文件路径。
func (s *LibraryService) ImportLibraryZip(zipPath, mode string, userID uint) (*LibraryImportReport, error) {
if mode != LibraryImportModeCreate && mode != LibraryImportModeOverwrite {
return nil, ErrLibraryImportMode
}
zr, err := zip.OpenReader(zipPath)
if err != nil {
return nil, ErrLibraryImportBadZip
}
defer zr.Close()
entries := make(map[string]*zip.File, len(zr.File))
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
entries[strings.ReplaceAll(f.Name, "\\", "/")] = f // 正常包无重名
}
plans, err := parseImportPlans(entries)
if err != nil {
return nil, err
}
if len(plans) == 0 {
return nil, ErrLibraryImportEmpty
}
rep := &LibraryImportReport{
Mode: mode,
Total: len(plans),
Imported: []LibraryImportItem{},
Failed: []LibraryImportFailure{},
}
imp := &bookImporter{s: s, entries: entries, userID: userID}
for _, p := range plans {
item, fail := imp.run(p, mode)
if fail != nil {
rep.Failed = append(rep.Failed, *fail)
} else {
rep.Imported = append(rep.Imported, *item)
}
}
return rep, nil
}
// parseImportPlans 识别单本/全库包并解析书籍清单(不读正文/附件)
func parseImportPlans(entries map[string]*zip.File) ([]bookImportPlan, error) {
if rootRaw, ok := entries["library.json"]; ok {
var root libraryExportManifest
if err := readZipJSON(rootRaw, &root); err != nil {
return nil, ErrLibraryImportFormat
}
if root.Format != libraryExportFormat || root.FormatVersion != libraryExportVersion {
return nil, ErrLibraryImportVer
}
plans := make([]bookImportPlan, 0, len(root.Docs))
for _, d := range root.Docs {
dir := strings.ReplaceAll(d.Dir, "\\", "/")
if !safeImportDir(dir) {
return nil, ErrLibraryImportFormat
}
raw, ok := entries[path.Clean(dir)+"/book.json"]
if !ok {
return nil, ErrLibraryImportFormat
}
var b libraryExportBook
if err := readZipJSON(raw, &b); err != nil {
return nil, ErrLibraryImportFormat
}
plans = append(plans, bookImportPlan{book: b, dir: dir})
}
return plans, nil
}
if rootRaw, ok := entries["book.json"]; ok {
var root libraryExportManifest
if err := readZipJSON(rootRaw, &root); err != nil {
return nil, ErrLibraryImportFormat
}
if root.Format != libraryExportBookFormat || root.FormatVersion != libraryExportVersion || root.Doc == nil {
return nil, ErrLibraryImportVer
}
return []bookImportPlan{{book: *root.Doc, dir: ""}}, nil
}
return nil, ErrLibraryImportFormat
}
// safeImportDir 全库包内目录必须是 docs/<slug>/ 形态(slug 与条目同规则)
func safeImportDir(dir string) bool {
if dir == "" || !strings.HasPrefix(dir, "docs/") || !strings.HasSuffix(dir, "/") {
return false
}
slug := strings.TrimSuffix(strings.TrimPrefix(dir, "docs/"), "/")
if strings.Contains(slug, "/") || strings.Contains(slug, "..") {
return false
}
return sitePageSlugRe.MatchString(slug)
}
// bookImporter 携带一次批量导入的共享上下文
type bookImporter struct {
s *LibraryService
entries map[string]*zip.File
userID uint
}
func (imp *bookImporter) fail(p bookImportPlan, reason, msg string) *LibraryImportFailure {
return &LibraryImportFailure{Slug: p.book.Slug, Title: p.book.Title, Reason: reason, Error: msg}
}
// run 导入单本书;返回 item 或 failure(二者互斥)
func (imp *bookImporter) run(p bookImportPlan, mode string) (*LibraryImportItem, *LibraryImportFailure) {
b := p.book
in, err := buildImportInput(&b)
if err != nil {
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
}
sections, err := buildImportSections(&b)
if err != nil {
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
}
if err := imp.checkPlannedFiles(p); err != nil {
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
}
coverEntry, err := imp.planCover(p)
if err != nil {
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
}
active, deleted, err := imp.s.slugTaken(b.Slug, 0)
if err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "查询条目失败")
}
var docID uint
action := "created"
var oldFiles []model.LibraryFile // overwrite 时待删磁盘附件
var oldCoverDisk string // overwrite 时待删旧封面(仅本地 /uploads)
backfillCreator := false // overwrite 且原条目无创建者时补记导入操作人
switch {
case active && mode != LibraryImportModeOverwrite:
return nil, imp.fail(p, libraryImportReasonConflict, "slug 已被在用条目占用:"+b.Slug)
case active:
var existing model.LibraryDoc
if err := imp.s.db.Where("slug = ?", b.Slug).First(&existing).Error; err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "读取原条目失败")
}
docID = existing.ID
// 跨站恢复不导出创建者:原条目无创建者时把本次导入操作人补为创建者
backfillCreator = existing.CreatorID == 0
_ = imp.s.db.Where("doc_id = ?", docID).Find(&oldFiles).Error
if disk, ok := imp.s.resolveCoverPath(existing.CoverURL); ok {
oldCoverDisk = disk
}
action = "overwritten"
case deleted:
return nil, imp.fail(p, libraryImportReasonDeleted,
"该地址被已删除条目占用,请先在书库管理中彻底删除后再导入:"+b.Slug)
}
// 内嵌封面先落盘(URL 在事务前确定;create 失败时随条目回滚删除)
coverDisk := ""
if coverEntry != nil {
url, w, h, disk, ferr := imp.materializeCover(coverEntry)
if ferr != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "封面恢复失败:"+ferr.Error())
}
in.CoverURL, in.CoverWidth, in.CoverHeight, coverDisk = url, w, h, disk
}
// 无内嵌封面时保留清单原值(外链或原站 /uploads URL)与尺寸
txErr := imp.s.db.Transaction(func(tx *gorm.DB) error {
if action == "created" {
d := &model.LibraryDoc{Published: false, SortOrder: 0, CreatorID: imp.userID}
in.applyTo(d)
if err := tx.Select(libraryDocWriteFields).Create(d).Error; err != nil {
return err
}
docID = d.ID
} else {
var d model.LibraryDoc
if err := tx.First(&d, docID).Error; err != nil {
return err
}
in.applyTo(&d)
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,
}
if backfillCreator {
updates["creator_id"] = imp.userID
}
if err := tx.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(updates).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error; err != nil {
return err
}
}
return imp.createSections(tx, docID, sections)
})
if txErr != nil {
if action == "created" && coverDisk != "" {
_ = os.Remove(coverDisk)
}
return nil, imp.fail(p, libraryImportReasonFailed, "写入数据库失败:"+txErr.Error())
}
// overwrite 事务成功后清理旧附件与旧封面磁盘文件(失败不影响结果)
for i := range oldFiles {
_ = os.Remove(imp.s.FilePath(&oldFiles[i]))
}
if oldCoverDisk != "" && oldCoverDisk != coverDisk {
_ = os.Remove(oldCoverDisk)
}
// 附件落盘。create 阶段失败需硬删新建条目以释放 slug;overwrite 仅报告失败
added := 0
var newFiles []model.LibraryFile
for _, mf := range b.Files {
if mf.Missing || mf.Stored == "" {
continue // 导出时磁盘已丢失的附件:清单保留记录但无文件可恢复
}
f, fail := imp.addPlannedFile(p, docID, mf)
if fail != nil {
if action == "created" {
imp.rollbackCreated(docID, newFiles, coverDisk)
}
return nil, fail
}
newFiles = append(newFiles, *f)
added++
}
return &LibraryImportItem{
Slug: b.Slug,
Title: b.Title,
Action: action,
Sections: len(sections),
Files: added,
}, nil
}
// ---------- 清单解析与预检 ----------
func buildImportInput(b *libraryExportBook) (*LibraryInput, error) {
pub, sortOrder, entriesAuto := b.Published, b.SortOrder, b.EntriesAuto
in := &LibraryInput{
Slug: b.Slug,
Title: b.Title,
Author: b.Author,
Description: b.Description,
CoverURL: b.CoverURL,
CoverWidth: b.CoverWidth,
CoverHeight: b.CoverHeight,
Published: &pub,
SortOrder: &sortOrder,
EntriesAuto: &entriesAuto,
}
if err := in.normalize(); err != nil {
return nil, err
}
return in, nil
}
// buildImportSections 两轮解析:先章(编号)后节(按父分组编号),校验标题/正文/父引用
func buildImportSections(b *libraryExportBook) ([]plannedSection, error) {
if len(b.Sections) > MaxSectionsPerDoc {
return nil, errors.New("章节数量超过上限(最多 200)")
}
keySeen := map[string]bool{}
out := make([]plannedSection, 0, len(b.Sections))
chapterSort := map[string]int{}
order := 0
for _, sec := range b.Sections {
if sec.ParentKey != "" {
continue
}
title := strings.TrimSpace(sec.Title)
if title == "" {
return nil, errors.New("存在标题为空的章节")
}
if err := validateSectionText(title, sec.Content); err != nil {
return nil, err
}
if keySeen[sec.Key] {
return nil, errors.New("章节标识重复:" + sec.Key)
}
keySeen[sec.Key] = true
chapterSort[sec.Key] = order
out = append(out, plannedSection{
key: sec.Key, title: title, content: sec.Content, sort: order,
})
order++
}
childCount := map[string]int{}
for _, sec := range b.Sections {
if sec.ParentKey == "" {
continue
}
if _, ok := chapterSort[sec.ParentKey]; !ok {
return nil, errors.New("小节「" + strings.TrimSpace(sec.Title) + "」找不到所属章节")
}
title := strings.TrimSpace(sec.Title)
if title == "" {
return nil, errors.New("存在标题为空的小节")
}
if err := validateSectionText(title, sec.Content); err != nil {
return nil, err
}
out = append(out, plannedSection{
key: sec.Key,
parentKey: sec.ParentKey,
title: title,
content: sec.Content,
sort: childCount[sec.ParentKey],
})
childCount[sec.ParentKey]++
}
return out, nil
}
func validateSectionText(title, content string) error {
if len([]rune(title)) > 200 {
return errors.New("章节标题不能超过 200 字:" + title)
}
if len([]rune(content)) > MaxSectionContent {
return errors.New("章节正文不能超过 100000 字:" + title)
}
return nil
}
// checkPlannedFiles 预检附件:数量、扩展名、ZIP 路径、声明大小、包内是否存在
func (imp *bookImporter) checkPlannedFiles(p bookImportPlan) error {
if len(p.book.Files) > MaxLibraryFilesPerDoc {
return errors.New("附件数量超过上限(最多 20 个)")
}
maxB := imp.s.maxBytes()
for _, f := range p.book.Files {
if f.Missing || f.Stored == "" {
continue
}
rel, ok := cleanZipRel(f.Stored)
if !ok || !strings.HasPrefix(rel, "files/") {
return errors.New("附件路径非法:" + f.Stored)
}
name := sanitizeFilename(f.Name)
if name == "" || !LibraryExtAllowed(ExtOfFilename(name)) {
return errors.New("附件格式不受支持:" + f.Name)
}
zf, ok := imp.entries[p.dir+rel]
if !ok {
return errors.New("备份包缺少附件文件:" + f.Name)
}
if zf.UncompressedSize64 > uint64(maxB) {
return errors.New("附件超过大小上限:" + f.Name)
}
}
return nil
}
// planCover 返回内嵌封面 ZIP 条目(无则 nil)
func (imp *bookImporter) planCover(p bookImportPlan) (*zip.File, error) {
cf := strings.TrimSpace(p.book.CoverFile)
if cf == "" {
return nil, nil
}
rel, ok := cleanZipRel(cf)
if !ok || strings.Contains(rel, "/") {
return nil, errors.New("封面路径非法:" + cf)
}
zf, ok := imp.entries[p.dir+rel]
if !ok {
return nil, errors.New("备份包缺少封面文件")
}
if zf.UncompressedSize64 > uint64(ImageMaxBytes) {
return nil, errors.New("封面不能超过 5MB")
}
return zf, nil
}
// ---------- 落盘 ----------
// createSections 两趟写入:先建章(key→新ID),再建节(父引用映射后的章 ID)
func (imp *bookImporter) createSections(tx *gorm.DB, docID uint, sections []plannedSection) error {
keyToID := make(map[string]uint, len(sections))
for i := range sections {
ps := &sections[i]
if ps.parentKey != "" {
continue
}
sec := &model.LibrarySection{DocID: docID, Title: ps.title, Content: ps.content, SortOrder: ps.sort}
if err := tx.Create(sec).Error; err != nil {
return err
}
keyToID[ps.key] = sec.ID
}
for i := range sections {
ps := &sections[i]
if ps.parentKey == "" {
continue
}
parentID, ok := keyToID[ps.parentKey]
if !ok {
return errors.New("小节找不到所属章节")
}
sec := &model.LibrarySection{
DocID: docID, ParentID: &parentID,
Title: ps.title, Content: ps.content, SortOrder: ps.sort,
}
if err := tx.Create(sec).Error; err != nil {
return err
}
}
return nil
}
// addPlannedFile 从 ZIP 读取附件并复用 AddFile 落盘,随后恢复下载计数与排序
func (imp *bookImporter) addPlannedFile(
p bookImportPlan, docID uint, mf libraryExportFile,
) (*model.LibraryFile, *LibraryImportFailure) {
rel, _ := cleanZipRel(mf.Stored)
zf, ok := imp.entries[p.dir+rel]
if !ok {
return nil, imp.fail(p, libraryImportReasonFailed, "备份包缺少附件文件:"+mf.Name)
}
rc, err := zf.Open()
if err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "读取附件失败:"+mf.Name)
}
defer rc.Close()
f, err := imp.s.AddFile(docID, imp.userID, mf.Name, io.LimitReader(rc, imp.s.maxBytes()+1))
if err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "附件恢复失败「"+mf.Name+"」:"+err.Error())
}
if err := imp.s.db.Model(&model.LibraryFile{}).Where("id = ?", f.ID).
UpdateColumns(map[string]interface{}{
"download_count": mf.DownloadCount,
"sort_order": mf.SortOrder,
}).Error; err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "附件信息写入失败:"+mf.Name)
}
return f, nil
}
// materializeCover 校验图片魔数后以随机名落盘 uploads/images,返回 URL/尺寸/磁盘路径
func (imp *bookImporter) materializeCover(zf *zip.File) (string, int, int, string, error) {
if imp.s.uploadsDir == "" {
return "", 0, 0, "", errors.New("未配置上传目录")
}
rc, err := zf.Open()
if err != nil {
return "", 0, 0, "", err
}
defer rc.Close()
data, err := io.ReadAll(io.LimitReader(rc, ImageMaxBytes+1))
if err != nil {
return "", 0, 0, "", err
}
if int64(len(data)) > ImageMaxBytes {
return "", 0, 0, "", errors.New("封面不能超过 5MB")
}
format, err := detectImageFormat(data)
if err != nil {
return "", 0, 0, "", err
}
width, height, err := decodeImageSizeReader(bytes.NewReader(data), format.mime)
if err != nil || width < 1 || height < 1 {
return "", 0, 0, "", errors.New("无法解析封面图片")
}
if width > ImageMaxDim || height > ImageMaxDim {
return "", 0, 0, "", errors.New("封面边长不能超过 4096px")
}
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
return "", 0, 0, "", err
}
filename := hex.EncodeToString(nameBytes) + format.ext
imagesDir := filepath.Join(imp.s.uploadsDir, "images")
if err := os.MkdirAll(imagesDir, 0o755); err != nil {
return "", 0, 0, "", err
}
full := filepath.Join(imagesDir, filename)
if err := os.WriteFile(full, data, 0o644); err != nil {
return "", 0, 0, "", err
}
return "/uploads/images/" + filename, width, height, full, nil
}
// rollbackCreated create 模式落盘阶段失败:硬删条目/章节/文件行与已落盘文件,释放 slug
func (imp *bookImporter) rollbackCreated(docID uint, newFiles []model.LibraryFile, coverDisk string) {
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error
_ = imp.s.db.Unscoped().Delete(&model.LibraryDoc{}, docID).Error
for i := range newFiles {
_ = os.Remove(imp.s.FilePath(&newFiles[i]))
}
if coverDisk != "" {
_ = os.Remove(coverDisk)
}
}
// ---------- ZIP 工具 ----------
// cleanZipRel 校验 ZIP 内相对路径:拒绝绝对路径与任何 ../ 穿越段
func cleanZipRel(rel string) (string, bool) {
p := path.Clean(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"))
if p == "." || p == "" || path.IsAbs(p) || p == ".." {
return "", false
}
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return "", false
}
}
return p, true
}
func readZipJSON(zf *zip.File, v any) error {
rc, err := zf.Open()
if err != nil {
return err
}
defer rc.Close()
return json.NewDecoder(rc).Decode(v)
}