补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
284 lines
7.4 KiB
Go
284 lines
7.4 KiB
Go
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
|
||
})
|
||
}
|