package service import ( "bytes" "crypto/rand" "encoding/hex" "errors" "image" _ "image/jpeg" _ "image/png" "io" "log" "os" "path/filepath" "strconv" "strings" "github.com/freefire/jiang13-bbs/model" "golang.org/x/image/webp" "gorm.io/gorm" ) // 头像限制:大小限制以【裁剪后】的 WebP 文件为准(前端裁剪为正方形后上传) const ( AvatarMaxBytes = 2 << 20 // 2 MiB AvatarMinDim = 64 AvatarMaxDim = 512 // 帖子插图:允许 JPEG/PNG/WebP,不强制转码 ImageMaxBytes = 5 << 20 // 5 MiB ImageMaxDim = 4096 // 站点背景图(前台/后台墙纸) BackgroundMaxBytes = 8 << 20 // 8 MiB BackgroundMaxDim = 8192 ) // UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments type UploadService struct { 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 } 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, } // 附件记录与用户头像更新在同一事务内完成 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 } return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP") } 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 } // SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 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" 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") } 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/" + filename, MIME: format.mime, Size: int(total), Width: w, Height: h, } if err := s.db.Create(att).Error; err != nil { _ = os.Remove(fullPath) 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" 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 } // 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/") || 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 } // 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 }