补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
260 lines
7.7 KiB
Go
260 lines
7.7 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"errors"
|
||
"image"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"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
|
||
)
|
||
|
||
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
|
||
type UploadService struct {
|
||
db *gorm.DB
|
||
dir string // 上传根目录(如 data/uploads)
|
||
}
|
||
|
||
func NewUploadService(db *gorm.DB, uploadDir string) *UploadService {
|
||
return &UploadService{db: db, dir: uploadDir}
|
||
}
|
||
|
||
// EnsureDir 启动时确保上传目录存在
|
||
func (s *UploadService) EnsureDir() error {
|
||
if err := os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755); err != nil {
|
||
return err
|
||
}
|
||
return os.MkdirAll(filepath.Join(s.dir, "images"), 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 decodeImageSize(data []byte, mime string) (w, h int, err error) {
|
||
r := bytes.NewReader(data)
|
||
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, data []byte) (*model.Attachment, error) {
|
||
if len(data) == 0 {
|
||
return nil, errors.New("文件为空")
|
||
}
|
||
if len(data) > ImageMaxBytes {
|
||
return nil, errors.New("图片不能超过 5MB")
|
||
}
|
||
format, err := detectImageFormat(data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
w, h, err := decodeImageSize(data, format.mime)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if w < 1 || h < 1 {
|
||
return nil, errors.New("无效的图片尺寸")
|
||
}
|
||
if w > ImageMaxDim || h > ImageMaxDim {
|
||
return nil, errors.New("图片边长不能超过 4096px")
|
||
}
|
||
|
||
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)
|
||
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
att := &model.Attachment{
|
||
UserID: userID,
|
||
Kind: model.AttachmentKindImage,
|
||
URL: "/uploads/images/" + filename,
|
||
MIME: format.mime,
|
||
Size: len(data),
|
||
Width: w,
|
||
Height: h,
|
||
}
|
||
if err := s.db.Create(att).Error; err != nil {
|
||
_ = os.Remove(fullPath)
|
||
return nil, err
|
||
}
|
||
return att, nil
|
||
}
|
||
|
||
// 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
|
||
}
|