Files
jiang13-bbs/backend/service/media_library.go
freefire 1d862a0dd3 feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
2026-09-27 02:36:11 +08:00

389 lines
11 KiB
Go
Raw 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
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", "品牌资源"},
}
// 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
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.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))
categoryName := func(key string) string {
for _, cat := range mediaLibraryCategories {
if cat.Key == key {
return cat.Name
}
}
return key
}
for _, r := range rows {
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
if !strings.HasPrefix(r.URL, "/api/media/") {
continue
}
uploader := r.Nickname
if uploader == "" {
uploader = r.Username
}
cat := r.Kind
if cat != model.AttachmentKindImage && cat != model.AttachmentKindAvatar {
continue // 未知 kind 不进媒体库
}
ca := r.CreatedAt
items = append(items, MediaLibraryItem{
URL: r.URL, Category: cat, CategoryName: categoryName(cat),
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
}
}
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 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
}
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}
}
}
}
}
}
// 评论来源要带宿主帖子标题,一次性补齐
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 {
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.id)
title := commentPostTitle[cm.postID]
if title != "" {
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
} else {
it.SourceLabel = "帖子评论"
}
break
}
}
}
}