Files
ttstd-infinite-canvas/backend/internal/app/task_media_storage.go

330 lines
9.9 KiB
Go

package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
"io"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"infinite-canvas/backend/internal/model"
)
const mediaStagingBudget int64 = 512 << 20
const mediaStagingTTL = 24 * time.Hour
var mediaStagingMu sync.Mutex
// Reserve the maximum response size before opening a file. Temporary files are
// sparse reservations, so concurrent downloads cannot oversubscribe this budget.
func (s *Service) newMediaTemp(limit int64) (*os.File, error) {
mediaStagingMu.Lock()
defer mediaStagingMu.Unlock()
dir := filepath.Join(s.dataDir, "media-staging")
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var used int64
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), "result-") || !entry.Type().IsRegular() {
continue
}
info, err := entry.Info()
if err != nil {
return nil, err
}
if time.Since(info.ModTime()) > mediaStagingTTL {
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil {
return nil, err
}
continue
}
used += info.Size()
}
if limit <= 0 || limit > mediaStagingBudget || used > mediaStagingBudget-limit {
return nil, errors.New("作品暂存空间不足,请稍后重试保存")
}
file, err := os.CreateTemp(dir, "result-*")
if err != nil {
return nil, err
}
if err := file.Truncate(limit); err != nil {
file.Close()
os.Remove(file.Name())
return nil, err
}
return file, nil
}
func (s *Service) stageInlineMedia(data []byte) (string, error) {
if len(data) == 0 {
return "", errors.New("上游返回空作品")
}
file, err := s.newMediaTemp(int64(len(data)))
if err != nil {
return "", err
}
_, err = file.Write(data)
if err == nil {
err = file.Sync()
}
closeErr := file.Close()
if err == nil {
err = closeErr
}
if err != nil {
os.Remove(file.Name())
return "", err
}
return filepath.Base(file.Name()), nil
}
func (s *Service) downloadTaskMedia(ctx context.Context, config providerConfig, url, mode string) (name, mimeType string, err error) {
policy, err := s.RuntimePolicy()
if err != nil {
return "", "", err
}
limit := min(megabytes(policy.Resource.GeneratedFileMB), mediaStagingBudget)
downloadCtx, cancel := context.WithTimeout(withProviderRequestKind(ctx, "download"), 3*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(downloadCtx, http.MethodGet, url, nil)
if err != nil {
return "", "", err
}
started := time.Now()
statusCode := 0
defer func() {
// Signed paths and net/url errors can contain bearer credentials. Keep
// the real error for retry classification, never in request logs.
logged := req.Clone(req.Context())
redactedURL := *req.URL
redactedURL.Path, redactedURL.RawPath, redactedURL.RawQuery, redactedURL.Fragment = "/task-media", "", "", ""
redactedURL.User = nil
logged.URL = &redactedURL
var safeErr error
if err != nil {
safeErr = errors.New("结果下载失败")
}
recordProviderRequest(logged, started, statusCode, nil, safeErr)
}()
if _, err = ValidateOutboundURL(url); err != nil {
return "", "", err
}
if sameProviderOrigin(config.BaseURL, url) {
applyProviderAuth(req, config)
ApplyOutboundHeaders(req, config.Headers)
}
ApplyDefaultOutboundHeaders(req)
response, err := OutboundHTTPClient(3 * time.Minute).Do(req)
if err != nil {
return "", "", err
}
defer response.Body.Close()
statusCode = response.StatusCode
if statusCode < 200 || statusCode >= 300 {
return "", "", providerHTTPError{StatusCode: statusCode, Status: response.Status, RetryAfter: parseRetryAfter(response.Header.Get("Retry-After"), time.Now())}
}
if response.ContentLength > limit {
return "", "", errors.New("生成文件超过大小限制")
}
if response.ContentLength > 0 {
limit = response.ContentLength
}
file, err := s.newMediaTemp(limit)
if err != nil {
return "", "", err
}
defer func() {
_ = file.Close()
if err != nil {
_ = os.Remove(file.Name())
}
}()
size, err := io.Copy(file, io.LimitReader(response.Body, limit+1))
if err != nil {
return "", "", err
}
if size == 0 || size > limit {
return "", "", errors.New("生成文件为空或超过大小限制")
}
if response.ContentLength >= 0 && size != response.ContentLength {
return "", "", io.ErrUnexpectedEOF
}
if err = file.Truncate(size); err != nil {
return "", "", err
}
if _, err = file.Seek(0, io.SeekStart); err != nil {
return "", "", err
}
head := make([]byte, min(size, 512))
if _, err = io.ReadFull(file, head); err != nil {
return "", "", err
}
mimeType = normalizedMediaMimeType(response.Header.Get("Content-Type"), head)
if mode == "audio" && mimeType == "application/ogg" {
mimeType = "audio/ogg"
}
if !strings.HasPrefix(mimeType, mode+"/") {
return "", "", errors.New("上游返回的内容不是有效的" + mode + "媒体")
}
if err = file.Sync(); err != nil {
return "", "", err
}
if err = file.Close(); err != nil {
return "", "", err
}
return filepath.Base(file.Name()), mimeType, nil
}
func mediaUploadKey(taskID string, index int) *string {
sum := sha256.Sum256([]byte(fmt.Sprintf("task-media:%s:%d", taskID, index)))
key := hex.EncodeToString(sum[:])
return &key
}
func (s *Service) storeTaskMediaFile(task *model.Task, index int, path, mimeType, kind string, existing *model.Resource) (*model.Resource, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, errors.New("暂存作品不是普通文件")
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
info, err = file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, errors.New("暂存作品不是普通文件")
}
width, height := 0, 0
durationMs := int64(0)
if kind == "image" {
config, _, err := image.DecodeConfig(file)
if err != nil {
// The standard library does not decode WebP/AVIF. Verify their
// container signatures instead of trusting a response MIME header.
head := make([]byte, 64)
n, _ := file.ReadAt(head, 0)
head = head[:n]
webp := mimeType == "image/webp" && n >= 12 && string(head[:4]) == "RIFF" && string(head[8:12]) == "WEBP"
avif := mimeType == "image/avif" && n >= 16 && string(head[4:8]) == "ftyp" && (strings.Contains(string(head[8:]), "avif") || strings.Contains(string(head[8:]), "avis"))
if !webp && !avif {
return nil, fmt.Errorf("图片内容校验失败:%w", err)
}
}
width, height = config.Width, config.Height
if _, err := file.Seek(0, io.SeekStart); err != nil {
return nil, err
}
} else if kind == "audio" {
// Audio duration is the source of truth for per-second settlement. The
// media can still be saved when ffprobe is unavailable; settlement then
// moves the order to review instead of silently charging a fake length.
durationMs = probeMediaDurationMs(path)
}
day, err := s.reserveGeneratedResourceQuota(task.UserID, info.Size())
if err != nil {
return nil, err
}
committed := false
defer func() {
if !committed {
s.releaseUserUploadQuota(task.UserID, day, info.Size())
}
}()
var resource *model.Resource
if existing == nil {
resource, _, err = s.storeResourceWithWriter(task.UserID, kind, "generated."+extensionFromMimeType(mimeType), mimeType, info.Size(), width, height, durationMs, file, mediaUploadKey(task.ID, index), false, s.storeTaskMediaObject)
} else {
// Reuse the same object key after a failed upload; no orphan per retry.
resource = existing
if resource.UserID != task.UserID || resource.Size != info.Size() || resource.MimeType != mimeType {
return nil, errors.New("恢复文件与原始资源不一致")
}
resource.DurationMs = durationMs
resource.ETag, err = s.storeTaskMediaObject(resource, "generated."+extensionFromMimeType(mimeType), file)
if err == nil {
resource.Status, resource.Error, resource.UpdatedAt = model.ResourceStatusReady, "", time.Now()
err = s.repo.SaveResource(resource)
}
}
if err != nil {
return nil, err
}
s.commitUserUploadQuota(task.UserID, info.Size())
committed = true
if existing != nil {
s.recordActivity(task.UserID, "resource", 1)
s.maybeStartPlaybackTranscode(resource)
}
return resource, nil
}
func probeMediaDurationMs(path string) int64 {
if strings.TrimSpace(path) == "" {
return 0
}
if _, err := exec.LookPath("ffprobe"); err != nil {
return 0
}
output, err := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path).Output()
if err != nil {
return 0
}
seconds, err := strconv.ParseFloat(strings.TrimSpace(string(output)), 64)
if err != nil || seconds <= 0 || math.IsNaN(seconds) || math.IsInf(seconds, 0) {
return 0
}
return int64(math.Ceil(seconds * 1000))
}
// Unlike ordinary uploads, generated output preserves an OSS failure for
// explicit recovery instead of silently changing the storage destination.
func (s *Service) storeTaskMediaObject(resource *model.Resource, _ string, body io.Reader) (string, error) {
if resource.Provider == "local" {
return "", writeLocalResourceObject(filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey)), body)
}
setting, err := s.ossSettingForResource(resource.UserID, resource)
if err != nil {
return "", err
}
return putOSSObject(s.storageSettingWithRuntimePolicy(setting), resource.ObjectKey, resource.MimeType, resource.Size, body)
}
// Cleanup is best effort only after durable completion. Expired leftovers are
// also collected on staging allocation, never by deleting resource objects.
func (s *Service) cleanupMediaCheckpointFiles(task *model.Task) {
checkpoint, err := s.decodeMediaCheckpoint(task)
if err != nil {
return
}
for _, item := range checkpoint.Items {
if path := s.mediaTempPath(item.TempName); path != "" {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
_ = s.log(task.UserID, task.ID, "warn", "清理作品暂存文件失败", "")
}
}
}
}