新增: - blob_store 二进制去重存储 + blob_dedup 测试 其它: - 后端 settings/auth/media_library/upload/operations 服务与 handler 调整 - 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
450 lines
13 KiB
Go
450 lines
13 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", "品牌资源"},
|
||
}
|
||
|
||
// 衍生分类:不对应 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"`
|
||
RefCount int `json:"ref_count,omitempty"` // 引用计数(>1 表示内容相同的重复上传共享此文件)
|
||
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
|
||
}
|
||
// 同 URL 多条记录(内容寻址去重后,重复上传共享同一文件):
|
||
// 上传者/时间取最早一条,计数即该文件的引用次数
|
||
type urlMeta struct {
|
||
meta attMeta
|
||
count int
|
||
}
|
||
agg := make(map[string]*urlMeta, len(rows))
|
||
for _, r := range rows {
|
||
if a, ok := agg[r.URL]; ok {
|
||
a.count++
|
||
if r.CreatedAt.Before(a.meta.CreatedAt) {
|
||
a.meta = r
|
||
}
|
||
} else {
|
||
agg[r.URL] = &urlMeta{meta: r, count: 1}
|
||
}
|
||
}
|
||
// 远程存储对象(/api/media/)不在磁盘上,按 URL 去重后直接作为条目加入
|
||
items := make([]MediaLibraryItem, 0, len(agg))
|
||
for url, a := range agg {
|
||
r := a.meta
|
||
if !strings.HasPrefix(url, "/api/media/") {
|
||
continue // 本地文件在磁盘扫描时按 URL 合并元数据
|
||
}
|
||
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: url, Category: catKey, CategoryName: catName,
|
||
Name: strings.TrimPrefix(url, "/api/media/"), MIME: r.MIME,
|
||
Size: int64(r.Size), Width: r.Width, Height: r.Height,
|
||
Uploader: uploader, UploadedAt: &ca, RefCount: a.count,
|
||
})
|
||
}
|
||
|
||
// 磁盘扫描五类目录;同名(去扩展名)成组,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 a, ok := agg[url]; ok {
|
||
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
|
||
m := a.meta
|
||
uploader := m.Nickname
|
||
if uploader == "" {
|
||
uploader = m.Username
|
||
}
|
||
ca := m.CreatedAt
|
||
item.Uploader = uploader
|
||
item.UploadedAt = &ca
|
||
item.RefCount = a.count
|
||
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
|
||
}
|
||
}
|
||
}
|
||
}
|