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

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

632 lines
20 KiB
Go
Raw Permalink 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
// 书库导入恢复:解析导出 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)
}