Files
ttstd-infinite-canvas/backend/internal/service/session_upload.go

130 lines
3.6 KiB
Go

package service
import (
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"infinite-canvas/backend/internal/model"
)
// sessionUploadCoordinator 只负责会话附件的文件落盘和资源额度生命周期。
//
// 会话创建、任务排队和素材/OSS 资源上传是不同的写路径。把附件写入从 Service
// 主流程移出后,文件系统失败、会话归属校验和额度回滚不再和任务 worker 共用一段
// 巨型方法,也便于用内存仓储覆盖失败分支。
type sessionUploadCoordinator struct {
repo sessionUploadRepository
dataDir string
runtimePolicy func() (RuntimePolicySetting, error)
reserveQuota func(userID string, size int64) (string, error)
releaseQuota func(userID string, day string, size int64)
commitQuota func(userID string, size int64)
}
type sessionUploadRepository interface {
SessionForUser(userID string, id string) (*model.Session, error)
Create(value any) error
}
func newSessionUploadCoordinator(s *Service) *sessionUploadCoordinator {
return &sessionUploadCoordinator{
repo: s.repo,
dataDir: s.dataDir,
runtimePolicy: s.RuntimePolicy,
reserveQuota: s.reserveSessionUploadQuota,
releaseQuota: s.releaseUserUploadQuota,
commitQuota: s.commitUserUploadQuota,
}
}
func (s *Service) sessionUpload() *sessionUploadCoordinator {
if s.sessionUploadCoordinator != nil {
return s.sessionUploadCoordinator
}
// 部分单元测试直接构造 Service 字面量;延迟创建保持这些测试和内部工具兼容。
return newSessionUploadCoordinator(s)
}
func (s *Service) StoreUpload(userID string, sessionID string, header *multipart.FileHeader) (*model.SessionFile, error) {
return s.sessionUpload().Store(userID, sessionID, header)
}
func (c *sessionUploadCoordinator) Store(userID string, sessionID string, header *multipart.FileHeader) (*model.SessionFile, error) {
policy, err := c.runtimePolicy()
if err != nil {
return nil, err
}
maxBytes := megabytes(policy.Resource.SessionUploadMB)
if header == nil || header.Size > maxBytes {
return nil, BadAuthRequest(fmt.Sprintf("会话文件不能超过 %dMB", policy.Resource.SessionUploadMB))
}
day, err := c.reserveQuota(userID, header.Size)
if err != nil {
return nil, err
}
reserved := true
defer func() {
if reserved {
c.releaseQuota(userID, day, header.Size)
}
}()
file, err := header.Open()
if err != nil {
return nil, err
}
defer file.Close()
uploadDir := filepath.Join(c.dataDir, "uploads")
if err := os.MkdirAll(uploadDir, 0o750); err != nil {
return nil, err
}
if strings.TrimSpace(sessionID) != "" {
if _, err := c.repo.SessionForUser(userID, sessionID); err != nil {
return nil, err
}
}
storedName := newID() + "-" + filepath.Base(header.Filename)
path := filepath.Join(uploadDir, storedName)
dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o640)
if err != nil {
return nil, err
}
size, err := io.Copy(dst, io.LimitReader(file, maxBytes+1))
closeErr := dst.Close()
if err != nil {
_ = os.Remove(path)
return nil, err
}
if closeErr != nil {
_ = os.Remove(path)
return nil, closeErr
}
if size > maxBytes {
_ = os.Remove(path)
return nil, BadAuthRequest(fmt.Sprintf("会话文件不能超过 %dMB", policy.Resource.SessionUploadMB))
}
item := model.SessionFile{
ID: newID(),
UserID: userID,
SessionID: sessionID,
FileName: header.Filename,
MimeType: header.Header.Get("Content-Type"),
Path: path,
Size: size,
}
if err := c.repo.Create(&item); err != nil {
_ = os.Remove(path)
return nil, err
}
c.commitQuota(userID, header.Size)
reserved = false
return &item, nil
}