链接支持网址/文字与站内搜索,新标签由全站设置控制;文章与评论共用已上传图片选择器。 Co-authored-by: Cursor <cursoragent@cursor.com>
582 lines
14 KiB
Go
582 lines
14 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/minio/minio-go/v7"
|
||
|
||
"git.iioio.com/freefire/jiang13-forum/config"
|
||
"git.iioio.com/freefire/jiang13-forum/model"
|
||
)
|
||
|
||
// MediaItem 管理端媒体资源条目
|
||
type MediaItem struct {
|
||
Category string `json:"category"`
|
||
Name string `json:"name"`
|
||
URL string `json:"url"`
|
||
Size int64 `json:"size"`
|
||
ModifiedAt time.Time `json:"modified_at"`
|
||
ContentType string `json:"content_type"`
|
||
StorageType string `json:"storage_type,omitempty"`
|
||
}
|
||
|
||
// MediaListResult 媒体列表分页结果
|
||
type MediaListResult struct {
|
||
Files []MediaItem `json:"files"`
|
||
Total int `json:"total"`
|
||
Page int `json:"page"`
|
||
TotalPages int `json:"total_pages"`
|
||
StorageType string `json:"storage_type"`
|
||
CategoryCounts map[string]int `json:"category_counts"`
|
||
}
|
||
|
||
var mediaCategories = []string{
|
||
UploadCategoryAvatars,
|
||
UploadCategoryPosts,
|
||
UploadCategorySite,
|
||
}
|
||
|
||
// ListMedia 从数据库索引列出媒体(上传/删除时维护;启动时会扫盘回填)
|
||
func (s *UploadStore) ListMedia(category, query string, page, size int) (*MediaListResult, error) {
|
||
if s == nil {
|
||
return nil, errors.New("上传存储未初始化")
|
||
}
|
||
if model.DB == nil {
|
||
return nil, errors.New("数据库未初始化")
|
||
}
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if size < 1 {
|
||
size = 24
|
||
}
|
||
if size > 100 {
|
||
size = 100
|
||
}
|
||
category = strings.ToLower(strings.TrimSpace(category))
|
||
if category == "" || category == "all" {
|
||
category = "all"
|
||
} else if !validMediaCategory(category) {
|
||
return nil, errors.New("无效的分类")
|
||
}
|
||
query = strings.TrimSpace(query)
|
||
|
||
// 索引为空时先同步一次,避免升级后首次打开空白
|
||
var indexed int64
|
||
_ = model.DB.Model(&model.Media{}).Count(&indexed).Error
|
||
if indexed == 0 {
|
||
_, _ = s.SyncMediaIndex()
|
||
}
|
||
|
||
counts := map[string]int{
|
||
UploadCategoryAvatars: 0,
|
||
UploadCategoryPosts: 0,
|
||
UploadCategorySite: 0,
|
||
}
|
||
type catCount struct {
|
||
Category string
|
||
Cnt int
|
||
}
|
||
var rows []catCount
|
||
if err := model.DB.Model(&model.Media{}).
|
||
Select("category, count(*) as cnt").
|
||
Group("category").
|
||
Scan(&rows).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
for _, r := range rows {
|
||
counts[r.Category] = r.Cnt
|
||
}
|
||
|
||
dbq := model.DB.Model(&model.Media{})
|
||
if category != "all" {
|
||
dbq = dbq.Where("category = ?", category)
|
||
}
|
||
if query != "" {
|
||
like := "%" + query + "%"
|
||
dbq = dbq.Where("name LIKE ? OR url LIKE ?", like, like)
|
||
}
|
||
|
||
var total int64
|
||
if err := dbq.Count(&total).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
totalPages := 1
|
||
if total > 0 {
|
||
totalPages = int((total + int64(size) - 1) / int64(size))
|
||
}
|
||
if page > totalPages {
|
||
page = totalPages
|
||
}
|
||
|
||
var records []model.Media
|
||
offset := (page - 1) * size
|
||
if err := dbq.Order("created_at desc, id desc").Offset(offset).Limit(size).Find(&records).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
files := make([]MediaItem, 0, len(records))
|
||
for _, r := range records {
|
||
mod := r.UpdatedAt
|
||
if mod.IsZero() {
|
||
mod = r.CreatedAt
|
||
}
|
||
files = append(files, MediaItem{
|
||
Category: r.Category,
|
||
Name: r.Name,
|
||
URL: r.URL,
|
||
Size: r.Size,
|
||
ModifiedAt: mod.UTC(),
|
||
ContentType: r.ContentType,
|
||
StorageType: r.StorageType,
|
||
})
|
||
}
|
||
|
||
mode, _, _, _ := s.snapshot()
|
||
storageType := config.StorageTypeLocal
|
||
if mode == config.StorageTypeS3 {
|
||
storageType = config.StorageTypeS3
|
||
}
|
||
|
||
return &MediaListResult{
|
||
Files: files,
|
||
Total: int(total),
|
||
Page: page,
|
||
TotalPages: totalPages,
|
||
StorageType: storageType,
|
||
CategoryCounts: counts,
|
||
}, nil
|
||
}
|
||
|
||
// ListUserPostImages 列出当前用户历史上传的帖子图片(category=posts)
|
||
func (s *UploadStore) ListUserPostImages(userID uint, page, size int) (*MediaListResult, error) {
|
||
if s == nil {
|
||
return nil, errors.New("上传存储未初始化")
|
||
}
|
||
if model.DB == nil {
|
||
return nil, errors.New("数据库未初始化")
|
||
}
|
||
if userID == 0 {
|
||
return nil, errors.New("未登录")
|
||
}
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if size < 1 {
|
||
size = 24
|
||
}
|
||
if size > 100 {
|
||
size = 100
|
||
}
|
||
|
||
var records []model.Media
|
||
if err := model.DB.Where("category = ? AND user_id = ?", UploadCategoryPosts, userID).
|
||
Order("created_at desc, id desc").
|
||
Find(&records).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 上传会登记原图 + WebP;图库按 stem 去重,优先展示 WebP(与插入 URL 一致)
|
||
records = dedupePostMediaPreferWebP(records)
|
||
|
||
total := len(records)
|
||
totalPages := 1
|
||
if total > 0 {
|
||
totalPages = (total + size - 1) / size
|
||
}
|
||
if page > totalPages {
|
||
page = totalPages
|
||
}
|
||
start := (page - 1) * size
|
||
if start > total {
|
||
start = total
|
||
}
|
||
end := start + size
|
||
if end > total {
|
||
end = total
|
||
}
|
||
pageRecords := records[start:end]
|
||
|
||
files := make([]MediaItem, 0, len(pageRecords))
|
||
for _, r := range pageRecords {
|
||
mod := r.UpdatedAt
|
||
if mod.IsZero() {
|
||
mod = r.CreatedAt
|
||
}
|
||
files = append(files, MediaItem{
|
||
Category: r.Category,
|
||
Name: r.Name,
|
||
URL: r.URL,
|
||
Size: r.Size,
|
||
ModifiedAt: mod.UTC(),
|
||
ContentType: r.ContentType,
|
||
StorageType: r.StorageType,
|
||
})
|
||
}
|
||
|
||
mode, _, _, _ := s.snapshot()
|
||
storageType := config.StorageTypeLocal
|
||
if mode == config.StorageTypeS3 {
|
||
storageType = config.StorageTypeS3
|
||
}
|
||
|
||
return &MediaListResult{
|
||
Files: files,
|
||
Total: total,
|
||
Page: page,
|
||
TotalPages: totalPages,
|
||
StorageType: storageType,
|
||
CategoryCounts: map[string]int{UploadCategoryPosts: total},
|
||
}, nil
|
||
}
|
||
|
||
// dedupePostMediaPreferWebP 同一上传的原图/WebP 只保留一条,优先 WebP
|
||
func dedupePostMediaPreferWebP(records []model.Media) []model.Media {
|
||
type slot struct {
|
||
idx int
|
||
isWebP bool
|
||
}
|
||
seen := map[string]slot{}
|
||
out := make([]model.Media, 0, len(records))
|
||
for _, r := range records {
|
||
stem := mediaFileStem(r.Name)
|
||
isWebP := strings.EqualFold(filepath.Ext(r.Name), ".webp") ||
|
||
strings.EqualFold(r.ContentType, "image/webp")
|
||
if s, ok := seen[stem]; ok {
|
||
if isWebP && !s.isWebP {
|
||
out[s.idx] = r
|
||
seen[stem] = slot{idx: s.idx, isWebP: true}
|
||
}
|
||
continue
|
||
}
|
||
seen[stem] = slot{idx: len(out), isWebP: isWebP}
|
||
out = append(out, r)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func mediaFileStem(name string) string {
|
||
name = filepath.Base(strings.TrimSpace(name))
|
||
ext := filepath.Ext(name)
|
||
if ext == "" {
|
||
return strings.ToLower(name)
|
||
}
|
||
return strings.ToLower(strings.TrimSuffix(name, ext))
|
||
}
|
||
|
||
// DeleteMedia 按 URL 批量删除媒体(含伴生扩展名与数据库索引)
|
||
func (s *UploadStore) DeleteMedia(urls []string) (int, error) {
|
||
if s == nil {
|
||
return 0, errors.New("上传存储未初始化")
|
||
}
|
||
n := 0
|
||
seen := map[string]bool{}
|
||
for _, u := range urls {
|
||
u = strings.TrimSpace(u)
|
||
if u == "" || seen[u] {
|
||
continue
|
||
}
|
||
seen[u] = true
|
||
if !s.isManagedMediaURL(u) {
|
||
continue
|
||
}
|
||
s.DeleteByURL(u)
|
||
n++
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// SyncMediaIndex 扫描当前存储后端,回填/校正媒体索引;返回写入或更新条数
|
||
func (s *UploadStore) SyncMediaIndex() (int, error) {
|
||
if s == nil || model.DB == nil {
|
||
return 0, errors.New("存储或数据库未初始化")
|
||
}
|
||
mode, _, _, _ := s.snapshot()
|
||
storageType := config.StorageTypeLocal
|
||
var items []MediaItem
|
||
var err error
|
||
if mode == config.StorageTypeS3 {
|
||
storageType = config.StorageTypeS3
|
||
items, err = s.listMediaS3("all")
|
||
} else {
|
||
items, err = s.listMediaLocal("all")
|
||
}
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
seen := make(map[string]struct{}, len(items))
|
||
n := 0
|
||
for _, it := range items {
|
||
seen[it.URL] = struct{}{}
|
||
if err := s.upsertMediaRecord(it.Category, it.Name, it.URL, it.Size, it.ContentType, storageType, nil); err != nil {
|
||
continue
|
||
}
|
||
n++
|
||
}
|
||
|
||
// 清理当前后端下已不存在的索引(其它后端记录保留)
|
||
var stale []model.Media
|
||
_ = model.DB.Where("storage_type = ?", storageType).Find(&stale).Error
|
||
for _, row := range stale {
|
||
if _, ok := seen[row.URL]; ok {
|
||
continue
|
||
}
|
||
_ = model.DB.Delete(&model.Media{}, row.ID).Error
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
func (s *UploadStore) upsertMediaRecord(category, name, url string, size int64, contentType, storageType string, userID *uint) error {
|
||
if model.DB == nil || strings.TrimSpace(url) == "" {
|
||
return nil
|
||
}
|
||
category = strings.TrimSpace(category)
|
||
name = strings.TrimSpace(name)
|
||
url = strings.TrimSpace(url)
|
||
if storageType == "" {
|
||
storageType = config.StorageTypeLocal
|
||
}
|
||
if contentType == "" {
|
||
contentType = imageContentType(strings.ToLower(filepath.Ext(name)))
|
||
}
|
||
|
||
var existing model.Media
|
||
err := model.DB.Where("url = ?", url).First(&existing).Error
|
||
if err == nil {
|
||
updates := map[string]interface{}{
|
||
"category": category,
|
||
"name": name,
|
||
"size": size,
|
||
"content_type": contentType,
|
||
"storage_type": storageType,
|
||
}
|
||
if userID != nil {
|
||
updates["user_id"] = *userID
|
||
}
|
||
return model.DB.Model(&existing).Updates(updates).Error
|
||
}
|
||
|
||
rec := model.Media{
|
||
Category: category,
|
||
Name: name,
|
||
URL: url,
|
||
Size: size,
|
||
ContentType: contentType,
|
||
StorageType: storageType,
|
||
UserID: userID,
|
||
}
|
||
return model.DB.Create(&rec).Error
|
||
}
|
||
|
||
func (s *UploadStore) deleteMediaRecords(urls []string) {
|
||
if model.DB == nil || len(urls) == 0 {
|
||
return
|
||
}
|
||
clean := make([]string, 0, len(urls))
|
||
seen := map[string]bool{}
|
||
for _, u := range urls {
|
||
u = strings.TrimSpace(u)
|
||
if u == "" || seen[u] {
|
||
continue
|
||
}
|
||
seen[u] = true
|
||
clean = append(clean, u)
|
||
}
|
||
if len(clean) == 0 {
|
||
return
|
||
}
|
||
_ = model.DB.Where("url IN ?", clean).Delete(&model.Media{}).Error
|
||
}
|
||
|
||
func (s *UploadStore) resolveSiblingPublicURLs(rawURL string) []string {
|
||
rawURL = strings.TrimSpace(rawURL)
|
||
if rawURL == "" {
|
||
return nil
|
||
}
|
||
var rel string
|
||
var basePrefix string // 拼回公开 URL 的前缀(含 category 前的部分)
|
||
|
||
if strings.HasPrefix(rawURL, "/uploads/") {
|
||
path := rawURL
|
||
if i := strings.Index(path, "?"); i >= 0 {
|
||
path = path[:i]
|
||
}
|
||
rel = strings.TrimPrefix(path, "/uploads/")
|
||
basePrefix = "/uploads/"
|
||
} else {
|
||
_, publicBase, _, _ := s.snapshot()
|
||
r, ok := relativeUnderPublicBase(rawURL, publicBase)
|
||
if !ok {
|
||
return []string{rawURL}
|
||
}
|
||
rel = r
|
||
basePrefix = strings.TrimRight(publicBase, "/") + "/"
|
||
}
|
||
|
||
siblings := uploadSiblingRels(rel)
|
||
if len(siblings) == 0 {
|
||
return []string{rawURL}
|
||
}
|
||
out := make([]string, 0, len(siblings))
|
||
for _, sib := range siblings {
|
||
out = append(out, basePrefix+sib)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func parseUploaderID(category, namePrefix string) *uint {
|
||
if category != UploadCategoryAvatars && category != UploadCategoryPosts {
|
||
return nil
|
||
}
|
||
id, err := strconv.ParseUint(strings.TrimSpace(namePrefix), 10, 64)
|
||
if err != nil || id == 0 {
|
||
return nil
|
||
}
|
||
u := uint(id)
|
||
return &u
|
||
}
|
||
|
||
func (s *UploadStore) isManagedMediaURL(rawURL string) bool {
|
||
rawURL = strings.TrimSpace(rawURL)
|
||
if rawURL == "" {
|
||
return false
|
||
}
|
||
if strings.HasPrefix(rawURL, "/uploads/") {
|
||
rel := strings.TrimPrefix(rawURL, "/uploads/")
|
||
cat, _, ok := splitCategoryName(rel)
|
||
return ok && validMediaCategory(cat)
|
||
}
|
||
_, publicBase, _, backend := s.snapshot()
|
||
if backend == nil || publicBase == "" {
|
||
return false
|
||
}
|
||
rel, ok := relativeUnderPublicBase(rawURL, publicBase)
|
||
if !ok {
|
||
return false
|
||
}
|
||
cat, _, ok := splitCategoryName(rel)
|
||
return ok && validMediaCategory(cat)
|
||
}
|
||
|
||
func (s *UploadStore) listMediaLocal(category string) ([]MediaItem, error) {
|
||
cats := mediaCategories
|
||
if category != "all" {
|
||
cats = []string{category}
|
||
}
|
||
var out []MediaItem
|
||
root := s.UploadsRoot()
|
||
for _, cat := range cats {
|
||
dir := filepath.Join(root, cat)
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
continue
|
||
}
|
||
return nil, err
|
||
}
|
||
for _, e := range entries {
|
||
if e.IsDir() {
|
||
continue
|
||
}
|
||
name := e.Name()
|
||
if strings.HasPrefix(name, ".") {
|
||
continue
|
||
}
|
||
ext := strings.ToLower(filepath.Ext(name))
|
||
if !allowedImageExt[ext] {
|
||
continue
|
||
}
|
||
info, err := e.Info()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
out = append(out, MediaItem{
|
||
Category: cat,
|
||
Name: name,
|
||
URL: "/uploads/" + cat + "/" + name,
|
||
Size: info.Size(),
|
||
ModifiedAt: info.ModTime().UTC(),
|
||
ContentType: imageContentType(ext),
|
||
StorageType: config.StorageTypeLocal,
|
||
})
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *UploadStore) listMediaS3(category string) ([]MediaItem, error) {
|
||
_, publicBase, keyPrefix, backend := s.snapshot()
|
||
if backend == nil {
|
||
return nil, errors.New("对象存储未就绪")
|
||
}
|
||
cats := mediaCategories
|
||
if category != "all" {
|
||
cats = []string{category}
|
||
}
|
||
var out []MediaItem
|
||
ctx := context.Background()
|
||
for _, cat := range cats {
|
||
prefix := keyPrefix + cat + "/"
|
||
for obj := range backend.client.ListObjects(ctx, backend.bucket, minio.ListObjectsOptions{
|
||
Prefix: prefix,
|
||
Recursive: true,
|
||
}) {
|
||
if obj.Err != nil {
|
||
return nil, obj.Err
|
||
}
|
||
if strings.HasSuffix(obj.Key, "/") {
|
||
continue
|
||
}
|
||
name := strings.TrimPrefix(obj.Key, prefix)
|
||
if name == "" || strings.Contains(name, "/") {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(name, ".") {
|
||
continue
|
||
}
|
||
ext := strings.ToLower(filepath.Ext(name))
|
||
if !allowedImageExt[ext] {
|
||
continue
|
||
}
|
||
out = append(out, MediaItem{
|
||
Category: cat,
|
||
Name: name,
|
||
URL: publicBase + "/" + cat + "/" + name,
|
||
Size: obj.Size,
|
||
ModifiedAt: obj.LastModified.UTC(),
|
||
ContentType: imageContentType(ext),
|
||
StorageType: config.StorageTypeS3,
|
||
})
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func validMediaCategory(cat string) bool {
|
||
switch cat {
|
||
case UploadCategoryAvatars, UploadCategoryPosts, UploadCategorySite:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func splitCategoryName(rel string) (category, name string, ok bool) {
|
||
rel = strings.TrimSpace(strings.ReplaceAll(rel, "\\", "/"))
|
||
rel = strings.TrimPrefix(rel, "/")
|
||
parts := strings.SplitN(rel, "/", 2)
|
||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||
return "", "", false
|
||
}
|
||
if strings.Contains(parts[1], "/") {
|
||
return "", "", false
|
||
}
|
||
return parts[0], parts[1], true
|
||
} |