新增: - blob_store 二进制去重存储 + blob_dedup 测试 其它: - 后端 settings/auth/media_library/upload/operations 服务与 handler 调整 - 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
820 lines
24 KiB
Go
820 lines
24 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"errors"
|
||
"image"
|
||
_ "image/gif"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
"io"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
webpenc "github.com/gen2brain/webp"
|
||
"golang.org/x/image/webp"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// 头像限制:大小限制以【裁剪后】的 WebP 文件为准(前端裁剪为正方形后上传)
|
||
const (
|
||
AvatarMaxBytes = 2 << 20 // 2 MiB
|
||
AvatarMinDim = 64
|
||
AvatarMaxDim = 512
|
||
|
||
// 帖子插图:JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留
|
||
ImageMaxBytes = 5 << 20 // 5 MiB(对原始上传文件生效)
|
||
ImageMaxDim = 4096
|
||
|
||
// JPEG 转 WebP 的有损质量;PNG 走无损,不受此参数影响
|
||
ImageWebPQuality = 80
|
||
|
||
// 站点背景图(前台/后台墙纸)
|
||
BackgroundMaxBytes = 8 << 20 // 8 MiB
|
||
BackgroundMaxDim = 8192
|
||
)
|
||
|
||
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
|
||
type UploadService struct {
|
||
ops *Operations
|
||
db *gorm.DB
|
||
dir string // 上传根目录(如 data/uploads)
|
||
setting *SettingService
|
||
}
|
||
|
||
func NewUploadService(db *gorm.DB, uploadDir string) *UploadService {
|
||
return &UploadService{db: db, dir: uploadDir}
|
||
}
|
||
|
||
// WithSetting 注入站点设置(插图体积上限)
|
||
func (s *UploadService) WithSetting(setting *SettingService) *UploadService {
|
||
s.setting = setting
|
||
return s
|
||
}
|
||
|
||
func (s *UploadService) imageMaxBytes() int64 {
|
||
if s.setting == nil {
|
||
return ImageMaxBytes
|
||
}
|
||
n, err := s.setting.ImageMaxBytes()
|
||
if err != nil || n < 1 {
|
||
return ImageMaxBytes
|
||
}
|
||
return n
|
||
}
|
||
|
||
// EnsureDir 启动时确保上传目录存在
|
||
func (s *UploadService) EnsureDir() error {
|
||
if err := os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755); err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(filepath.Join(s.dir, "images"), 0o755); err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755); err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(filepath.Join(s.dir, "ads"), 0o755); err != nil {
|
||
return err
|
||
}
|
||
return os.MkdirAll(filepath.Join(s.dir, "brand"), 0o755)
|
||
}
|
||
|
||
// SaveAvatar 保存裁剪后的 WebP 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像
|
||
func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment, error) {
|
||
if len(data) == 0 {
|
||
return nil, errors.New("文件为空")
|
||
}
|
||
if len(data) > AvatarMaxBytes {
|
||
return nil, errors.New("裁剪后的图片不能超过 2MB")
|
||
}
|
||
// WebP 魔数:RIFF????WEBP
|
||
if len(data) < 12 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WEBP" {
|
||
return nil, errors.New("仅支持 WebP 格式图片")
|
||
}
|
||
|
||
// 解码尺寸(服务端不信任前端传入的宽高)
|
||
cfg, err := webp.DecodeConfig(bytes.NewReader(data))
|
||
if err != nil {
|
||
return nil, errors.New("无法解析 WebP 图片")
|
||
}
|
||
if cfg.Width != cfg.Height {
|
||
return nil, errors.New("头像必须为正方形")
|
||
}
|
||
if cfg.Width < AvatarMinDim || cfg.Width > AvatarMaxDim {
|
||
return nil, errors.New("头像尺寸需在 64~512px 之间")
|
||
}
|
||
|
||
hash := sha256Hex(data)
|
||
filename := hash + ".webp"
|
||
fullPath := filepath.Join(s.dir, "avatars", filename)
|
||
url := "/uploads/avatars/" + filename
|
||
var objectID string
|
||
writeLocal := func() error {
|
||
if _, err := os.Stat(fullPath); err == nil {
|
||
return nil // 同内容头像已落盘,字节必然一致,无需重写
|
||
}
|
||
return os.WriteFile(fullPath, data, 0o644)
|
||
}
|
||
if s.ops != nil {
|
||
if err := writeLocal(); err != nil {
|
||
return nil, err
|
||
}
|
||
id, e := s.ops.StoreFileHashed(fullPath, "image/webp", true, hash)
|
||
if e != nil {
|
||
_ = os.Remove(fullPath)
|
||
return nil, e
|
||
}
|
||
objectID = id
|
||
if id != "" {
|
||
url = "/api/media/" + id
|
||
_ = os.Remove(fullPath)
|
||
}
|
||
} else if err := writeLocal(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
att := &model.Attachment{
|
||
UserID: userID,
|
||
Kind: model.AttachmentKindAvatar,
|
||
URL: url,
|
||
BlobHash: hash,
|
||
MIME: "image/webp",
|
||
Size: len(data),
|
||
Width: cfg.Width,
|
||
Height: cfg.Height,
|
||
}
|
||
// 附件记录、blob 引用与用户头像更新在同一事务内完成
|
||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||
if err := acquireBlobRef(tx, hash, true, len(data), "image/webp", objectID); err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Create(att).Error; err != nil {
|
||
return err
|
||
}
|
||
return tx.Model(&model.User{}).Where("id = ?", userID).Update("avatar", att.URL).Error
|
||
})
|
||
if err != nil {
|
||
s.releaseBlobBestEffort(hash, true, "/uploads/avatars/"+filename)
|
||
return nil, err
|
||
}
|
||
return att, nil
|
||
}
|
||
|
||
// imageFormat 由魔数识别的插图格式
|
||
type imageFormat struct {
|
||
ext string
|
||
mime string
|
||
}
|
||
|
||
func detectImageFormat(data []byte) (imageFormat, error) {
|
||
if len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff {
|
||
return imageFormat{ext: ".jpg", mime: "image/jpeg"}, nil
|
||
}
|
||
if len(data) >= 8 && string(data[0:8]) == "\x89PNG\r\n\x1a\n" {
|
||
return imageFormat{ext: ".png", mime: "image/png"}, nil
|
||
}
|
||
if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||
return imageFormat{ext: ".webp", mime: "image/webp"}, nil
|
||
}
|
||
if len(data) >= 6 && (string(data[0:6]) == "GIF87a" || string(data[0:6]) == "GIF89a") {
|
||
return imageFormat{ext: ".gif", mime: "image/gif"}, nil
|
||
}
|
||
return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP / GIF")
|
||
}
|
||
|
||
func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) {
|
||
var cfg image.Config
|
||
switch mime {
|
||
case "image/webp":
|
||
cfg, err = webp.DecodeConfig(r)
|
||
default:
|
||
cfg, _, err = image.DecodeConfig(r)
|
||
}
|
||
if err != nil {
|
||
return 0, 0, errors.New("无法解析图片")
|
||
}
|
||
return cfg.Width, cfg.Height, nil
|
||
}
|
||
|
||
// transcodeImageToWebP 把校验通过的 JPEG/PNG 临时文件解码后转码为 WebP:
|
||
// JPEG 走有损 q80,PNG 走无损(保留透明通道与文字锐度)。
|
||
// 成功返回新文件名/MIME/大小并清理原临时文件;失败返回 error(调用方保留原格式落盘)。
|
||
func (s *UploadService) transcodeImageToWebP(tmp, name, ext string) (string, string, int64, error) {
|
||
webpName := strings.TrimSuffix(name, ext) + ".webp"
|
||
webpPath := filepath.Join(s.dir, "images", webpName)
|
||
webpTmp := webpPath + ".partial"
|
||
if s.ops != nil {
|
||
release, e := s.ops.BeginTemporary(webpTmp)
|
||
if e != nil {
|
||
return "", "", 0, e
|
||
}
|
||
defer release()
|
||
}
|
||
|
||
f, err := os.Open(tmp)
|
||
if err != nil {
|
||
return "", "", 0, err
|
||
}
|
||
img, _, err := image.Decode(f) // 仅 JPEG/PNG 会走到这里,解码器已在文件头注册
|
||
_ = f.Close()
|
||
if err != nil {
|
||
return "", "", 0, err
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := webpenc.Encode(&buf, img, webpenc.Options{
|
||
Quality: ImageWebPQuality,
|
||
Lossless: ext == ".png",
|
||
}); err != nil {
|
||
return "", "", 0, err
|
||
}
|
||
if err := os.WriteFile(webpTmp, buf.Bytes(), 0o644); err != nil {
|
||
_ = os.Remove(webpTmp)
|
||
return "", "", 0, err
|
||
}
|
||
if err := os.Rename(webpTmp, webpPath); err != nil {
|
||
_ = os.Remove(webpTmp)
|
||
return "", "", 0, err
|
||
}
|
||
_ = os.Remove(tmp)
|
||
return webpName, "image/webp", int64(buf.Len()), nil
|
||
}
|
||
|
||
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image)
|
||
func (s *UploadService) SaveImage(userID uint, src io.Reader, source string) (*model.Attachment, error) {
|
||
if src == nil {
|
||
return nil, errors.New("文件为空")
|
||
}
|
||
maxB := s.imageMaxBytes()
|
||
tooLarge := func() error {
|
||
mb := int(maxB >> 20)
|
||
if mb < 1 {
|
||
mb = 1
|
||
}
|
||
return errors.New("图片不能超过 " + strconv.Itoa(mb) + "MB")
|
||
}
|
||
|
||
// 先读魔数头,再与剩余流合并落盘,整图不进内存
|
||
head := make([]byte, 12)
|
||
n, err := io.ReadFull(src, head)
|
||
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
|
||
return nil, errors.New("读取图片失败")
|
||
}
|
||
if n == 0 {
|
||
return nil, errors.New("文件为空")
|
||
}
|
||
format, err := detectImageFormat(head[:n])
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
nameBytes := make([]byte, 16)
|
||
if _, err := rand.Read(nameBytes); err != nil {
|
||
return nil, err
|
||
}
|
||
filename := hex.EncodeToString(nameBytes) + format.ext
|
||
fullPath := filepath.Join(s.dir, "images", filename)
|
||
tmp := fullPath + ".partial"
|
||
if s.ops != nil {
|
||
release, e := s.ops.BeginTemporary(tmp)
|
||
if e != nil {
|
||
return nil, e
|
||
}
|
||
defer release()
|
||
}
|
||
|
||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
remain := maxB + 1 - int64(n)
|
||
if remain < 0 {
|
||
_ = f.Close()
|
||
_ = os.Remove(tmp)
|
||
return nil, tooLarge()
|
||
}
|
||
writtenHead, err := f.Write(head[:n])
|
||
if err != nil {
|
||
_ = f.Close()
|
||
_ = os.Remove(tmp)
|
||
return nil, err
|
||
}
|
||
writtenRest, copyErr := io.Copy(f, io.LimitReader(src, remain))
|
||
_ = f.Close()
|
||
if copyErr != nil {
|
||
_ = os.Remove(tmp)
|
||
return nil, copyErr
|
||
}
|
||
total := int64(writtenHead) + writtenRest
|
||
if total == 0 {
|
||
_ = os.Remove(tmp)
|
||
return nil, errors.New("文件为空")
|
||
}
|
||
if total > maxB {
|
||
_ = os.Remove(tmp)
|
||
return nil, tooLarge()
|
||
}
|
||
|
||
rf, err := os.Open(tmp)
|
||
if err != nil {
|
||
_ = os.Remove(tmp)
|
||
return nil, err
|
||
}
|
||
w, h, err := decodeImageSizeReader(rf, format.mime)
|
||
_ = rf.Close()
|
||
if err != nil {
|
||
_ = os.Remove(tmp)
|
||
return nil, err
|
||
}
|
||
if w < 1 || h < 1 {
|
||
_ = os.Remove(tmp)
|
||
return nil, errors.New("无效的图片尺寸")
|
||
}
|
||
if w > ImageMaxDim || h > ImageMaxDim {
|
||
_ = os.Remove(tmp)
|
||
return nil, errors.New("图片边长不能超过 4096px")
|
||
}
|
||
|
||
// JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留
|
||
storeName, storeMime, storeSize := filename, format.mime, total
|
||
storePath := fullPath
|
||
if format.mime == "image/jpeg" || format.mime == "image/png" {
|
||
name, mime, size, err := s.transcodeImageToWebP(tmp, filename, format.ext)
|
||
if err != nil {
|
||
log.Printf("[upload] 插图转 WebP 失败,保留原格式 %s: %v", filename, err)
|
||
} else {
|
||
storeName, storeMime, storeSize = name, mime, size
|
||
storePath = filepath.Join(s.dir, "images", name)
|
||
}
|
||
}
|
||
if storePath == fullPath {
|
||
if err := os.Rename(tmp, fullPath); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
// 对最终存储字节做内容寻址(JPEG/PNG 已在上面转成 WebP,同图重复上传才能命中)
|
||
hash, err := hashFile(storePath)
|
||
if err != nil {
|
||
_ = os.Remove(storePath)
|
||
return nil, err
|
||
}
|
||
finalName := hash + strings.ToLower(filepath.Ext(storeName))
|
||
url := "/uploads/images/" + finalName
|
||
var objectID string
|
||
if s.ops != nil {
|
||
id, e := s.ops.StoreFileHashed(storePath, storeMime, true, hash)
|
||
if e != nil {
|
||
_ = os.Remove(storePath)
|
||
return nil, e
|
||
}
|
||
objectID = id
|
||
if id != "" {
|
||
// 远程后端:以对象 ID 为访问地址,本地临时副本回收
|
||
url = "/api/media/" + id
|
||
_ = os.Remove(storePath)
|
||
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
|
||
return nil, e
|
||
}
|
||
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
|
||
return nil, e
|
||
}
|
||
|
||
att := &model.Attachment{
|
||
UserID: userID,
|
||
Kind: model.AttachmentKindImage,
|
||
Source: normalizeImageSource(source),
|
||
URL: url,
|
||
BlobHash: hash,
|
||
MIME: storeMime,
|
||
Size: int(storeSize),
|
||
Width: w,
|
||
Height: h,
|
||
}
|
||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||
if err := acquireBlobRef(tx, hash, true, int(storeSize), storeMime, objectID); err != nil {
|
||
return err
|
||
}
|
||
return tx.Create(att).Error
|
||
}); err != nil {
|
||
s.releaseBlobBestEffort(hash, true, url)
|
||
return nil, err
|
||
}
|
||
return att, nil
|
||
}
|
||
|
||
// SaveBackground 保存站点背景图到 uploads/backgrounds,不进用户媒体库
|
||
func (s *UploadService) SaveBackground(src io.Reader) (string, error) {
|
||
if src == nil {
|
||
return "", errors.New("文件为空")
|
||
}
|
||
tooLarge := func() error {
|
||
return errors.New("背景图不能超过 8MB")
|
||
}
|
||
|
||
head := make([]byte, 12)
|
||
n, err := io.ReadFull(src, head)
|
||
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
|
||
return "", errors.New("读取图片失败")
|
||
}
|
||
if n == 0 {
|
||
return "", errors.New("文件为空")
|
||
}
|
||
format, err := detectImageFormat(head[:n])
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if err := os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
nameBytes := make([]byte, 16)
|
||
if _, err := rand.Read(nameBytes); err != nil {
|
||
return "", err
|
||
}
|
||
filename := hex.EncodeToString(nameBytes) + format.ext
|
||
fullPath := filepath.Join(s.dir, "backgrounds", filename)
|
||
tmp := fullPath + ".partial"
|
||
if s.ops != nil {
|
||
release, e := s.ops.BeginTemporary(tmp)
|
||
if e != nil {
|
||
return "", e
|
||
}
|
||
defer release()
|
||
}
|
||
|
||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
remain := BackgroundMaxBytes + 1 - int64(n)
|
||
if remain < 0 {
|
||
_ = f.Close()
|
||
_ = os.Remove(tmp)
|
||
return "", tooLarge()
|
||
}
|
||
if _, err := f.Write(head[:n]); err != nil {
|
||
_ = f.Close()
|
||
_ = os.Remove(tmp)
|
||
return "", err
|
||
}
|
||
writtenRest, copyErr := io.Copy(f, io.LimitReader(src, remain))
|
||
_ = f.Close()
|
||
if copyErr != nil {
|
||
_ = os.Remove(tmp)
|
||
return "", copyErr
|
||
}
|
||
total := int64(n) + writtenRest
|
||
if total > BackgroundMaxBytes {
|
||
_ = os.Remove(tmp)
|
||
return "", tooLarge()
|
||
}
|
||
|
||
rf, err := os.Open(tmp)
|
||
if err != nil {
|
||
_ = os.Remove(tmp)
|
||
return "", err
|
||
}
|
||
w, h, err := decodeImageSizeReader(rf, format.mime)
|
||
_ = rf.Close()
|
||
if err != nil {
|
||
_ = os.Remove(tmp)
|
||
return "", err
|
||
}
|
||
if w < 1 || h < 1 {
|
||
_ = os.Remove(tmp)
|
||
return "", errors.New("无效的图片尺寸")
|
||
}
|
||
if w > BackgroundMaxDim || h > BackgroundMaxDim {
|
||
_ = os.Remove(tmp)
|
||
return "", errors.New("背景图边长不能超过 8192px")
|
||
}
|
||
if err := os.Rename(tmp, fullPath); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return "", err
|
||
}
|
||
return "/uploads/backgrounds/" + filename, nil
|
||
}
|
||
|
||
const AdAssetMaxBytes = 2 << 20 // 收款码 / 广告素材 2MB
|
||
|
||
// SaveAdAsset 保存广告相关图片(收款码等)到 uploads/ads,保留原格式含 GIF
|
||
func (s *UploadService) SaveAdAsset(src io.Reader) (string, error) {
|
||
if src == nil {
|
||
return "", errors.New("文件为空")
|
||
}
|
||
tooLarge := func() error { return errors.New("图片不能超过 2MB") }
|
||
|
||
head := make([]byte, 12)
|
||
n, err := io.ReadFull(src, head)
|
||
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
|
||
return "", errors.New("读取图片失败")
|
||
}
|
||
if n == 0 {
|
||
return "", errors.New("文件为空")
|
||
}
|
||
format, err := detectImageFormat(head[:n])
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if err := os.MkdirAll(filepath.Join(s.dir, "ads"), 0o755); err != nil {
|
||
return "", err
|
||
}
|
||
nameBytes := make([]byte, 16)
|
||
if _, err := rand.Read(nameBytes); err != nil {
|
||
return "", err
|
||
}
|
||
filename := hex.EncodeToString(nameBytes) + format.ext
|
||
fullPath := filepath.Join(s.dir, "ads", filename)
|
||
tmp := fullPath + ".partial"
|
||
if s.ops != nil {
|
||
release, e := s.ops.BeginTemporary(tmp)
|
||
if e != nil {
|
||
return "", e
|
||
}
|
||
defer release()
|
||
}
|
||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
remain := AdAssetMaxBytes + 1 - int64(n)
|
||
if remain < 0 {
|
||
_ = f.Close()
|
||
_ = os.Remove(tmp)
|
||
return "", tooLarge()
|
||
}
|
||
if _, err := f.Write(head[:n]); err != nil {
|
||
_ = f.Close()
|
||
_ = os.Remove(tmp)
|
||
return "", err
|
||
}
|
||
writtenRest, copyErr := io.Copy(f, io.LimitReader(src, remain))
|
||
_ = f.Close()
|
||
if copyErr != nil {
|
||
_ = os.Remove(tmp)
|
||
return "", copyErr
|
||
}
|
||
if int64(n)+writtenRest > AdAssetMaxBytes {
|
||
_ = os.Remove(tmp)
|
||
return "", tooLarge()
|
||
}
|
||
rf, err := os.Open(tmp)
|
||
if err != nil {
|
||
_ = os.Remove(tmp)
|
||
return "", err
|
||
}
|
||
w, h, err := decodeImageSizeReader(rf, format.mime)
|
||
_ = rf.Close()
|
||
if err != nil {
|
||
_ = os.Remove(tmp)
|
||
return "", err
|
||
}
|
||
if w < 1 || h < 1 || w > 4096 || h > 4096 {
|
||
_ = os.Remove(tmp)
|
||
return "", errors.New("图片尺寸无效")
|
||
}
|
||
if err := os.Rename(tmp, fullPath); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return "", err
|
||
}
|
||
return "/uploads/ads/" + filename, nil
|
||
}
|
||
|
||
// CopyBackgroundFromMedia 把当前用户媒体库里的一张图复制进 backgrounds,不写站点设置
|
||
func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (string, error) {
|
||
var att model.Attachment
|
||
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
||
return "", errors.New("图片不存在或不属于你")
|
||
}
|
||
if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true)
|
||
if e != nil {
|
||
return "", e
|
||
}
|
||
defer r.Close()
|
||
return s.SaveBackground(r)
|
||
}
|
||
abs, ok := s.safeUploadPath(att.URL)
|
||
if !ok {
|
||
return "", errors.New("无效的图片地址")
|
||
}
|
||
f, err := os.Open(abs)
|
||
if err != nil {
|
||
return "", errors.New("读取图片失败")
|
||
}
|
||
defer f.Close()
|
||
return s.SaveBackground(f)
|
||
}
|
||
|
||
// normalizeImageSource 把外部传入的图片来源收敛到白名单,未知值回退为 post
|
||
func normalizeImageSource(s string) string {
|
||
switch s {
|
||
case model.AttachmentSourceLibraryCover, model.AttachmentSourceLibraryContent:
|
||
return s
|
||
default:
|
||
return model.AttachmentSourcePost
|
||
}
|
||
}
|
||
|
||
// CopyImageFromMedia 把当前用户媒体库里的一张图复制一份新插图(落 uploads/images 并记一条本人附件)
|
||
// 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用
|
||
// source 决定副本的来源标记(如 library_cover)
|
||
func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint, source string) (*model.Attachment, error) {
|
||
var att model.Attachment
|
||
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
||
return nil, errors.New("图片不存在或不属于你")
|
||
}
|
||
if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true)
|
||
if e != nil {
|
||
return nil, e
|
||
}
|
||
defer r.Close()
|
||
return s.SaveImage(userID, r, source)
|
||
}
|
||
abs, ok := s.safeUploadPath(att.URL)
|
||
if !ok {
|
||
return nil, errors.New("无效的图片地址")
|
||
}
|
||
f, err := os.Open(abs)
|
||
if err != nil {
|
||
return nil, errors.New("读取图片失败")
|
||
}
|
||
defer f.Close()
|
||
return s.SaveImage(userID, f, source)
|
||
}
|
||
|
||
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
|
||
func (s *UploadService) BackgroundFileExists(url string) bool {
|
||
rel := BgRelPath(url)
|
||
if rel == "" {
|
||
return false
|
||
}
|
||
abs := filepath.Join(s.dir, rel)
|
||
info, err := os.Stat(abs)
|
||
return err == nil && !info.IsDir()
|
||
}
|
||
|
||
// RemoveBackgroundIfUnused 旧墙纸不再被前台或后台引用时删除物理文件
|
||
func (s *UploadService) RemoveBackgroundIfUnused(oldURL, siteURL, adminURL string) {
|
||
if oldURL == "" || oldURL == siteURL || oldURL == adminURL {
|
||
return
|
||
}
|
||
rel := BgRelPath(oldURL)
|
||
if rel == "" {
|
||
return
|
||
}
|
||
abs := filepath.Join(s.dir, rel)
|
||
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
|
||
log.Printf("[upload] 删除背景图失败 path=%s: %v", abs, err)
|
||
}
|
||
}
|
||
|
||
// UseAvatar 选用一张【本人历史上传】的头像
|
||
func (s *UploadService) UseAvatar(userID uint, url string) error {
|
||
url = strings.TrimSpace(url)
|
||
if (!strings.HasPrefix(url, "/uploads/avatars/") && !strings.HasPrefix(url, "/api/media/")) || len(url) > 512 {
|
||
return errors.New("无效的头像地址")
|
||
}
|
||
var count int64
|
||
s.db.Model(&model.Attachment{}).
|
||
Where("user_id = ? AND kind = ? AND url = ?", userID, model.AttachmentKindAvatar, url).
|
||
Count(&count)
|
||
if count == 0 {
|
||
return errors.New("该图片不存在或不属于你")
|
||
}
|
||
return s.db.Model(&model.User{}).Where("id = ?", userID).Update("avatar", url).Error
|
||
}
|
||
|
||
// mediaListSize 媒体库最多返回的图片数
|
||
const mediaListSize = 60
|
||
|
||
// ListMedia 媒体库:本人上传的【全部】图片(头像、帖子插图等所有 kind),最新在前
|
||
func (s *UploadService) ListMedia(userID uint) ([]model.Attachment, error) {
|
||
var list []model.Attachment
|
||
err := s.db.Where("user_id = ?", userID).
|
||
Order("id DESC").Limit(mediaListSize).Find(&list).Error
|
||
return list, err
|
||
}
|
||
|
||
// FindMediaBySource 按来源查本人附件(存在返回记录,不存在返回 error)
|
||
func (s *UploadService) FindMediaBySource(userID, attachmentID uint, source string) (*model.Attachment, error) {
|
||
var att model.Attachment
|
||
if err := s.db.Where("id = ? AND user_id = ? AND source = ?", attachmentID, userID, source).First(&att).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return &att, nil
|
||
}
|
||
|
||
// ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除
|
||
var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")
|
||
|
||
// DeleteAttachment 彻底删除本人附件:DB 记录 + 物理文件。
|
||
// - 若该图正被某篇未删除帖子的正文引用,拒绝删除(防止帖子里出现裂图)
|
||
// - 若该图正是当前头像,同步把 user.avatar 置空(回到字母头像)
|
||
func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
|
||
var att model.Attachment
|
||
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
||
return errors.New("图片不存在或不属于你")
|
||
}
|
||
|
||
// 帖子正文引用保护(GORM 默认过滤已删除帖子)
|
||
var refCount int64
|
||
s.db.Model(&model.Post{}).
|
||
Where("content LIKE ?", "%"+att.URL+"%").
|
||
Count(&refCount)
|
||
if refCount > 0 {
|
||
return ErrAttachmentInUse
|
||
}
|
||
|
||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||
// 当前头像正指向该文件:置空,避免悬挂引用
|
||
if err := tx.Model(&model.User{}).
|
||
Where("id = ? AND avatar = ?", userID, att.URL).
|
||
Update("avatar", "").Error; err != nil {
|
||
return err
|
||
}
|
||
return tx.Delete(&model.Attachment{}, att.ID).Error
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if att.BlobHash != "" {
|
||
return s.releaseAttachmentBlob(&att)
|
||
}
|
||
|
||
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
|
||
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
|
||
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
||
}
|
||
return nil
|
||
}
|
||
// DB 已清理后再删物理文件;文件删除失败只记日志(不影响用户侧结果)
|
||
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(att.URL, "/uploads/")))
|
||
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
|
||
log.Printf("[upload] 删除附件文件失败 id=%d path=%s: %v", att.ID, abs, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// releaseAttachmentBlob 公开图片 blob 引用计数-1;最后一个引用删除时回收物理文件
|
||
func (s *UploadService) releaseAttachmentBlob(att *model.Attachment) error {
|
||
b, last, err := releaseBlobRef(s.db, att.BlobHash, true)
|
||
if err != nil || !last {
|
||
return err
|
||
}
|
||
return s.removePublicBlobObject(b, att.URL)
|
||
}
|
||
|
||
// releaseBlobBestEffort 业务记录落库失败时回滚刚加的引用,错误只记日志
|
||
func (s *UploadService) releaseBlobBestEffort(hash string, public bool, localURL string) {
|
||
b, last, err := releaseBlobRef(s.db, hash, public)
|
||
if err != nil {
|
||
log.Printf("[upload] 回滚 blob 引用失败 hash=%s: %v", hash, err)
|
||
return
|
||
}
|
||
if !last {
|
||
return
|
||
}
|
||
if err := s.removePublicBlobObject(b, localURL); err != nil {
|
||
log.Printf("[upload] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
|
||
}
|
||
}
|
||
|
||
// removePublicBlobObject 引用归零后的物理回收:远程对象失败需上抛(与历史行为一致),
|
||
// 本地文件失败只记日志(下次同内容上传会自愈覆盖)。
|
||
func (s *UploadService) removePublicBlobObject(b model.Blob, localURL string) error {
|
||
if b.ObjectID != "" && s.ops != nil {
|
||
if e := s.ops.RemoveObject(b.ObjectID); e != nil {
|
||
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
||
}
|
||
return nil
|
||
}
|
||
if localURL == "" {
|
||
return nil
|
||
}
|
||
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(localURL, "/uploads/")))
|
||
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
|
||
log.Printf("[upload] 删除 blob 文件失败 hash=%s path=%s: %v", b.Hash, abs, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }
|