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

341 lines
10 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 app
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
"io"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"yingce/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)
// 结果文件可能托管在渠道同域(sameProviderOrigin 时还带渠道鉴权),
// 渠道配置了出站代理时下载必须走同一代理,否则配代理渠道的结果无法取回。
proxyURL := ""
if metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext); ok {
proxyURL = metadata.ProxyURL
}
if strings.TrimSpace(proxyURL) == "" && strings.TrimSpace(config.ChannelID) != "" {
if ch, err := s.repo.SystemChannel(config.ChannelID); err == nil {
proxyURL = ch.ProxyURL
}
}
response, err := OutboundHTTPClientWithProxy(3*time.Minute, proxyURL).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.saveResourceWithinStorageLimit(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", "清理作品暂存文件失败", "")
}
}
}
}