移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
466
service/media.go
Normal file
466
service/media.go
Normal file
@@ -0,0 +1,466 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user