- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
389 lines
11 KiB
Go
389 lines
11 KiB
Go
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
|
||
}
|
||
}
|
||
}
|
||
}
|