package service import ( "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/freefire/jiang13-bbs/model" ) // 媒体库(管理后台):盘点站点内全部图片资源。 // 数据源 = uploads 五个分类目录的磁盘扫描 + attachments 表元数据合并: // - 旧站导入图 / 背景图 / 广告素材 / 品牌 Logo 落盘但无附件记录,只有磁盘扫描能覆盖 // - 附件记录(头像、帖子插图)补充上传者、创建时间等元数据;远程存储对象(/api/media/)仅存在于库中 // 同名(去扩展名)同时存在原图与 WebP 时只展示 WebP,原图 URL 挂在 OriginalURL 供查看。 // 媒体库分类:目录名即分类键,前端 tab 直接使用 var mediaLibraryCategories = []struct { Key string // 分类键(image/avatar/background/ads/brand) Dir string // uploads 下的子目录 Name string // 展示名 }{ {"image", "images", "帖子插图"}, {"avatar", "avatars", "用户头像"}, {"background", "backgrounds", "站点背景"}, {"ads", "ads", "广告素材"}, {"brand", "brand", "品牌资源"}, } // 衍生分类:不对应 uploads 子目录,按附件 source 归类 const ( mediaCategoryBook = "book" mediaCategoryBookName = "书籍" ) // mediaCategoryForAttachment 附件在媒体库中的展示分类: // 书库封面 / 正文插图(source=library_cover|library_content)统一归“书籍”, // 其余按 kind 对应磁盘目录分类。 func mediaCategoryForAttachment(kind, source string) (key, name string) { if source == model.AttachmentSourceLibraryCover || source == model.AttachmentSourceLibraryContent { return mediaCategoryBook, mediaCategoryBookName } for _, cat := range mediaLibraryCategories { if cat.Key == kind { return cat.Key, cat.Name } } return kind, kind } // MediaLibraryItem 媒体库条目 type MediaLibraryItem struct { URL string `json:"url"` // 展示地址(有 WebP 时为 WebP) OriginalURL string `json:"original_url,omitempty"` // 同名原图(仅当原图与 WebP 并存) Category string `json:"category"` // image/avatar/background/ads/brand CategoryName string `json:"category_name"` Name string `json:"name"` // 文件名(含扩展名) MIME string `json:"mime"` Size int64 `json:"size"` Width int `json:"width"` Height int `json:"height"` Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空) UploadedAt *time.Time `json:"uploaded_at,omitempty"` SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页) SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论) } // AdminMediaLibrary 全站图片盘点:磁盘扫描 + 附件元数据合并,按时间倒序。 // 返回条目列表与各分类计数(含全部计数由前端按列表累加)。 func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int, error) { // 附件元数据索引:URL → 记录(联出上传者昵称) type attMeta struct { Kind string Source string URL string MIME string Size int Width int Height int Nickname string Username string CreatedAt time.Time } var rows []attMeta if err := s.db.Table("attachments"). Select("attachments.kind, attachments.source, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username"). Joins("LEFT JOIN users ON users.id = attachments.user_id"). Scan(&rows).Error; err != nil { return nil, nil, err } metaByURL := make(map[string]attMeta, len(rows)) // 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入 items := make([]MediaLibraryItem, 0, len(rows)) for _, r := range rows { metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据 if !strings.HasPrefix(r.URL, "/api/media/") { continue } if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar { continue // 未知 kind 不进媒体库 } uploader := r.Nickname if uploader == "" { uploader = r.Username } catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source) ca := r.CreatedAt items = append(items, MediaLibraryItem{ URL: r.URL, Category: catKey, CategoryName: catName, Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME, Size: int64(r.Size), Width: r.Width, Height: r.Height, Uploader: uploader, UploadedAt: &ca, }) } // 磁盘扫描五类目录;同名(去扩展名)成组,WebP 优先展示、原图挂 OriginalURL type group struct { webp *MediaLibraryItem original *MediaLibraryItem } for _, cat := range mediaLibraryCategories { dir := filepath.Join(s.dir, cat.Dir) entries, err := os.ReadDir(dir) if err != nil { continue // 目录不存在视为空 } groups := map[string]*group{} for _, e := range entries { if e.IsDir() || strings.HasSuffix(e.Name(), ".partial") { continue } ext := strings.ToLower(filepath.Ext(e.Name())) switch ext { case ".jpg", ".jpeg", ".png", ".webp", ".gif": default: continue } url := "/uploads/" + cat.Dir + "/" + e.Name() item := MediaLibraryItem{ URL: url, Category: cat.Key, CategoryName: cat.Name, Name: e.Name(), MIME: map[string]string{ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif", }[ext], } if info, err := e.Info(); err == nil { item.Size = info.Size() mt := info.ModTime() item.UploadedAt = &mt } if m, ok := metaByURL[url]; ok { // 附件记录有上传者与准确创建时间,覆盖磁盘信息 uploader := m.Nickname if uploader == "" { uploader = m.Username } ca := m.CreatedAt item.Uploader = uploader item.UploadedAt = &ca if m.Size > 0 { item.Size = int64(m.Size) } if m.Width > 0 { item.Width, item.Height = m.Width, m.Height } // 书库封面 / 正文插图改归“书籍”分类 if m.Source == model.AttachmentSourceLibraryCover || m.Source == model.AttachmentSourceLibraryContent { item.Category = mediaCategoryBook item.CategoryName = mediaCategoryBookName } } // 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0) if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 { if f, err := os.Open(filepath.Join(dir, e.Name())); err == nil { w, h, derr := decodeImageSizeReader(f, item.MIME) _ = f.Close() if derr == nil { item.Width, item.Height = w, h } } } stem := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) g := groups[stem] if g == nil { g = &group{} groups[stem] = g } if ext == ".webp" { g.webp = &item } else if g.original == nil { g.original = &item } } for _, g := range groups { switch { case g.webp != nil && g.original != nil: // 原图与 WebP 并存:只展示 WebP,原图作 original_url g.webp.OriginalURL = g.original.URL if g.webp.Size == 0 { g.webp.Size = g.original.Size } items = append(items, *g.webp) case g.webp != nil: items = append(items, *g.webp) default: items = append(items, *g.original) } } } sort.Slice(items, func(i, j int) bool { ti, tj := items[i].UploadedAt, items[j].UploadedAt switch { case ti == nil && tj == nil: return items[i].URL < items[j].URL case ti == nil: return false case tj == nil: return true } return ti.After(*tj) }) counts := map[string]int{} for _, it := range items { counts[it.Category]++ } s.attachMediaSources(items) return items, counts, nil } // attachMediaSources 为条目补来源回链: // - 头像:users.avatar 精确匹配 → 用户主页 // - 其余:分块 LIKE 扫描帖子/评论正文(展示 URL 与同名原图都参与匹配),帖子优先于评论 // // 内容 LIKE 无索引,纯顺序扫描,但仅管理端低频调用;分块 OR 控制往返次数。 func (s *UploadService) attachMediaSources(items []MediaLibraryItem) { urlSet := map[string]bool{} for _, it := range items { for _, u := range []string{it.URL, it.OriginalURL} { if strings.HasPrefix(u, "/uploads/") { urlSet[u] = true } } } if len(urlSet) == 0 { return } urls := make([]string, 0, len(urlSet)) for u := range urlSet { urls = append(urls, u) } type postRef struct { id uint title string } postByURL := map[string]postRef{} type commentRef struct { id, postID uint floor uint isRoot bool } commentByURL := map[string]commentRef{} for i := 0; i < len(urls); i += 40 { end := i + 40 if end > len(urls) { end = len(urls) } chunk := urls[i:end] conds := make([]string, len(chunk)) args := make([]any, len(chunk)) for j, u := range chunk { conds[j] = "content LIKE ?" args[j] = "%" + u + "%" } where := strings.Join(conds, " OR ") var prows []struct { ID uint Title string Content string } if err := s.db.Model(&model.Post{}).Select("id, title, content"). Where(where, args...).Order("id ASC").Limit(600).Scan(&prows).Error; err != nil { return } for _, p := range prows { for _, u := range chunk { if strings.Contains(p.Content, u) { if _, ok := postByURL[u]; !ok { postByURL[u] = postRef{id: p.ID, title: p.Title} } } } } var crows []struct { ID uint PostID uint Content string } if err := s.db.Model(&model.Comment{}).Select("id, post_id, content"). Where(where, args...).Order("id ASC").Limit(600).Scan(&crows).Error; err != nil { return } for _, cm := range crows { for _, u := range chunk { if strings.Contains(cm.Content, u) { if _, ok := commentByURL[u]; !ok { commentByURL[u] = commentRef{id: cm.ID, postID: cm.PostID} } } } } } // 评论锚点改用所属帖内相对楼层号(#comment-{floor},楼中楼追加 -r{id}) commentIDs := make([]uint, 0, len(commentByURL)) for _, cm := range commentByURL { commentIDs = append(commentIDs, cm.id) } commentAnchors := CommentAnchors(s.db, commentIDs) for u, cm := range commentByURL { if a, ok := commentAnchors[cm.id]; ok { cm.floor, cm.isRoot = a.Floor, a.IsRoot commentByURL[u] = cm } } // 评论来源要带宿主帖子标题,一次性补齐 postTitle := func(id uint) string { var p model.Post if err := s.db.Select("id, title").First(&p, id).Error; err != nil { return "" } return p.Title } commentPostTitle := map[uint]string{} for _, cm := range commentByURL { if _, ok := commentPostTitle[cm.postID]; !ok { commentPostTitle[cm.postID] = postTitle(cm.postID) } } // 头像使用者:users.avatar 精确匹配(同一头像多人使用时取最早的用户) userByAvatar := map[string]struct { ID uint Nickname, Username string }{} var urows []struct { ID uint Nickname string Username string Avatar string } if err := s.db.Model(&model.User{}).Select("id, nickname, username, avatar"). Where("avatar IN ?", urls).Order("id ASC").Find(&urows).Error; err == nil { for _, u := range urows { if _, ok := userByAvatar[u.Avatar]; !ok { userByAvatar[u.Avatar] = struct { ID uint Nickname, Username string }{ID: u.ID, Nickname: u.Nickname, Username: u.Username} } } } truncate := func(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "…" } postLabel := func(p postRef) string { title := p.title if title == "" { title = fmt.Sprintf("#%d", p.id) } return "帖子《" + truncate(title, 40) + "》" } for i := range items { it := &items[i] // 头像使用者优先(头像正文引用场景极少) if u, ok := userByAvatar[it.URL]; ok { name := u.Nickname if name == "" { name = u.Username } it.SourceURL = fmt.Sprintf("/u/%d", u.ID) it.SourceLabel = "@" + name + " 的头像" continue } for _, u := range []string{it.URL, it.OriginalURL} { if u == "" || it.SourceURL != "" { continue } if p, ok := postByURL[u]; ok { it.SourceURL = fmt.Sprintf("/post/%d", p.id) it.SourceLabel = postLabel(p) break } if cm, ok := commentByURL[u]; ok { if cm.floor > 0 { if cm.isRoot { it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.floor) } else { it.SourceURL = fmt.Sprintf("/post/%d#comment-%d-r%d", cm.postID, cm.floor, cm.id) } } else { it.SourceURL = fmt.Sprintf("/post/%d", cm.postID) } title := commentPostTitle[cm.postID] if title != "" { it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论" } else { it.SourceLabel = "帖子评论" } break } } } }