Files
jiang13-bbs/backend/service/upload.go
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

719 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 之间")
}
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
return nil, err
}
filename := hex.EncodeToString(nameBytes) + ".webp"
fullPath := filepath.Join(s.dir, "avatars", filename)
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
return nil, err
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindAvatar,
URL: "/uploads/avatars/" + filename,
MIME: "image/webp",
Size: len(data),
Width: cfg.Width,
Height: cfg.Height,
}
if s.ops != nil {
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
if e != nil {
_ = os.Remove(fullPath)
return nil, e
}
if id != "" {
att.URL = "/api/media/" + id
_ = os.Remove(fullPath)
}
}
// 附件记录与用户头像更新在同一事务内完成
err = s.db.Transaction(func(tx *gorm.DB) error {
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 {
_ = os.Remove(fullPath) // DB 失败时清理已落盘文件
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) (*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
}
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindImage,
URL: "/uploads/images/" + storeName,
MIME: storeMime,
Size: int(storeSize),
Width: w,
Height: h,
}
if s.ops != nil {
id, e := s.ops.StoreFile(storePath, att.MIME, true)
if e != nil {
_ = os.Remove(storePath)
return nil, e
}
if id != "" {
att.URL = "/api/media/" + id
_ = os.Remove(storePath)
}
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(storePath)
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)
}
// CopyImageFromMedia 把当前用户媒体库里的一张图复制一份新插图(落 uploads/images 并记一条本人附件)
// 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用
func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*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)
}
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)
}
// 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
}
// 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 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
}
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }