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 与条目同规则) 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 := §ions[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 := §ions[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) }