Files
jiang13-bbs/backend/service/post_file.go
freefire b9ba8cb8c9 feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 03:17:07 +08:00

284 lines
7.4 KiB
Go
Raw 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 (
"crypto/rand"
"encoding/hex"
"errors"
"mime"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
const (
FileMaxBytes = 20 << 20 // 20 MiB
MaxPostAttachments = 10
)
var (
ErrAttachmentNotFound = errors.New("附件不存在")
ErrAttachmentForbidden = errors.New("无权操作此附件")
ErrTooManyAttachments = errors.New("附件数量超过上限")
)
// PostFileService 帖子文件附件(私有目录 + API 下载)
type PostFileService struct {
db *gorm.DB
dir string // data/private/files
}
func NewPostFileService(db *gorm.DB, privateDir string) *PostFileService {
return &PostFileService{db: db, dir: filepath.Join(privateDir, "files")}
}
func (s *PostFileService) EnsureDir() error {
return os.MkdirAll(s.dir, 0o755)
}
func absPath(dir, stored string) string {
return filepath.Join(dir, stored)
}
// SaveDraftFile 上传附件(先挂 post_id=0,发帖时绑定)
func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data []byte, pricePoints int) (*model.PostAttachment, error) {
if len(data) == 0 {
return nil, errors.New("文件为空")
}
if len(data) > FileMaxBytes {
return nil, errors.New("附件不能超过 20MB")
}
if pricePoints < 0 {
pricePoints = 0
}
if pricePoints > 100000 {
return nil, errors.New("积分定价过高")
}
name := sanitizeFilename(originalName)
if name == "" {
name = "file"
}
var orphan int64
if err := s.db.Model(&model.PostAttachment{}).
Where("user_id = ? AND post_id = 0", userID).Count(&orphan).Error; err != nil {
return nil, err
}
if orphan >= MaxPostAttachments {
return nil, ErrTooManyAttachments
}
ext := filepath.Ext(name)
if utf8.RuneCountInString(ext) > 16 {
ext = ""
}
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
stored := hex.EncodeToString(raw) + ext
full := absPath(s.dir, stored)
if err := os.WriteFile(full, data, 0o600); err != nil {
return nil, err
}
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
att := &model.PostAttachment{
PostID: 0,
UserID: userID,
Name: name,
StoredName: stored,
MIME: mimeType,
Size: len(data),
PricePoints: pricePoints,
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(full)
return nil, err
}
return att, nil
}
func sanitizeFilename(name string) string {
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
name = strings.TrimSpace(name)
name = strings.Map(func(r rune) rune {
switch r {
case '/', '\\', '\x00', ':', '*', '?', '"', '<', '>', '|':
return '_'
default:
return r
}
}, name)
if utf8.RuneCountInString(name) > 200 {
runes := []rune(name)
name = string(runes[:200])
}
return name
}
// BindToPost 将草稿附件绑定到帖子(仅本人、未绑定)
func (s *PostFileService) BindToPost(userID, postID uint, ids []uint) error {
if len(ids) == 0 {
return nil
}
if len(ids) > MaxPostAttachments {
return ErrTooManyAttachments
}
return s.db.Transaction(func(tx *gorm.DB) error {
var atts []model.PostAttachment
if err := tx.Where("id IN ? AND user_id = ? AND post_id = 0", ids, userID).Find(&atts).Error; err != nil {
return err
}
if len(atts) != len(ids) {
return errors.New("部分附件无效或无权使用")
}
return tx.Model(&model.PostAttachment{}).
Where("id IN ? AND user_id = ? AND post_id = 0", ids, userID).
Update("post_id", postID).Error
})
}
// ReplacePostAttachments 编辑时重绑附件列表(ids 为最终列表;可含已绑定本帖的)
func (s *PostFileService) ReplacePostAttachments(userID, postID uint, ids []uint) error {
if len(ids) > MaxPostAttachments {
return ErrTooManyAttachments
}
return s.db.Transaction(func(tx *gorm.DB) error {
var keep []model.PostAttachment
if len(ids) > 0 {
if err := tx.Where(
"id IN ? AND user_id = ? AND (post_id = 0 OR post_id = ?)",
ids, userID, postID,
).Find(&keep).Error; err != nil {
return err
}
if len(keep) != len(ids) {
return errors.New("部分附件无效或无权使用")
}
}
// 解绑本帖旧附件(软删物理文件可选:P1 仅解绑)
if err := tx.Model(&model.PostAttachment{}).
Where("post_id = ? AND user_id = ?", postID, userID).
Update("post_id", 0).Error; err != nil {
return err
}
if len(ids) == 0 {
return nil
}
return tx.Model(&model.PostAttachment{}).
Where("id IN ? AND user_id = ?", ids, userID).
Update("post_id", postID).Error
})
}
// ListByPost 帖子附件列表
func (s *PostFileService) ListByPost(postID uint) ([]model.PostAttachment, error) {
var list []model.PostAttachment
err := s.db.Where("post_id = ?", postID).Order("id ASC").Find(&list).Error
return list, err
}
// UpdatePrice 更新附件积分定价(作者)
func (s *PostFileService) UpdatePrice(userID, attID uint, price int) error {
if price < 0 {
price = 0
}
res := s.db.Model(&model.PostAttachment{}).
Where("id = ? AND user_id = ?", attID, userID).
Update("price_points", price)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrAttachmentNotFound
}
return nil
}
// DeleteOwn 删除本人未绑定或本帖附件
func (s *PostFileService) DeleteOwn(userID, attID uint) error {
var att model.PostAttachment
if err := s.db.First(&att, attID).Error; err != nil {
return ErrAttachmentNotFound
}
if att.UserID != userID {
return ErrAttachmentForbidden
}
path := absPath(s.dir, att.StoredName)
if err := s.db.Delete(&att).Error; err != nil {
return err
}
_ = os.Remove(path)
return nil
}
// OpenForDownload 鉴权后打开文件;需先确认帖子可见与积分
func (s *PostFileService) Get(attID uint) (*model.PostAttachment, error) {
var att model.PostAttachment
if err := s.db.First(&att, attID).Error; err != nil {
return nil, ErrAttachmentNotFound
}
return &att, nil
}
func (s *PostFileService) FilePath(att *model.PostAttachment) string {
return absPath(s.dir, att.StoredName)
}
func (s *PostFileService) IncDownload(attID uint) {
s.db.Model(&model.PostAttachment{}).Where("id = ?", attID).
UpdateColumn("download_count", gorm.Expr("download_count + 1"))
}
// EnsureAttachmentUnlocked 免费或已购/作者;积分附件扣费一次
func (s *PostFileService) EnsureAttachmentUnlocked(userID uint, att *model.PostAttachment) error {
if att.PricePoints <= 0 {
return nil
}
if att.UserID == userID {
return nil
}
var n int64
if err := s.db.Model(&model.PostAttachmentUnlock{}).
Where("attachment_id = ? AND user_id = ?", att.ID, userID).
Count(&n).Error; err != nil {
return err
}
if n > 0 {
return nil
}
return s.db.Transaction(func(tx *gorm.DB) error {
var again int64
if err := tx.Model(&model.PostAttachmentUnlock{}).
Where("attachment_id = ? AND user_id = ?", att.ID, userID).
Count(&again).Error; err != nil {
return err
}
if again > 0 {
return nil
}
if _, err := DebitTx(tx, userID, att.PricePoints, model.PointReasonDownloadFile, "attachment", att.ID, "下载附件:"+att.Name); err != nil {
return err
}
// 积分转给作者
if att.UserID > 0 && att.UserID != userID {
if _, err := CreditTx(tx, att.UserID, att.PricePoints, model.PointReasonDownloadFile, "attachment_earn", att.ID, "附件收益:"+att.Name); err != nil {
return err
}
}
return tx.Create(&model.PostAttachmentUnlock{
AttachmentID: att.ID,
UserID: userID,
Points: att.PricePoints,
}).Error
})
}