feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
388
backend/service/media_library.go
Normal file
388
backend/service/media_library.go
Normal file
@@ -0,0 +1,388 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user