package service import ( "bytes" "crypto/rand" "encoding/hex" "errors" "io" "net/http" "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", "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 条目 + 文件列表 + 章节树(管理端与公开详情共用) type LibraryDocDetail struct { model.LibraryDoc 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 纯文本截断,含文件概要) type LibraryDocListItem struct { 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=不修改) } 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.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 字") } 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.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 } 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) uploadsDir string // data/uploads:封面回填时解析本地封面图 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 } // 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) } 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) } creatorByID := s.creatorMap(docs) 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, Creator: creatorByID[d.CreatorID], }) } return out } // 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 } 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} 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 } 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);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, }).Error; err != nil { return nil, err } 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 { 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, 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, }) } 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 } // 懒回填:有封面但未存尺寸时当场测量并写回(首次访问后即稳定,消除横竖版探测闪烁) s.ensureCoverDimensions(&d) 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")) }