Files
ttstd-infinite-canvas/backend/internal/handler/resource_upload_session.go

323 lines
9.6 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 handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"github.com/gin-gonic/gin"
"yingce/backend/internal/service"
)
// 分片上传会话:把“导入本地媒体”拆成 开始→逐片→合并 三段,单片上限 8MB,
// 整体文件仍遵守资源单文件上限;收片前在数据库预留日额度和存储容量。
// 分片路由需要粘性路由,内存会话失效后重传;配额预留跨实例共享并按时间到期。
const (
chunkUploadChunkSize = 8 << 20
chunkUploadSlackBytes = 64 << 10 // MaxBytesReader 允许的超片余量
chunkUploadTTL = 90 * time.Minute
chunkUploadMaxPerUser = 32
chunkUploadBodyCapJSON = 16 << 10
)
type chunkedUploadSession struct {
mu sync.Mutex
closed bool
release func() error
ID string
UserID string
FileName string
Kind string
Size int64
Width int
Height int
DurationMs int64
IdempotencyKey string
ChunkCount int
Dir string
CreatedAt time.Time
}
var chunkUploadSessions = struct {
sync.Mutex
m map[string]*chunkedUploadSession
}{m: make(map[string]*chunkedUploadSession)}
func newUploadSessionID() string {
raw := make([]byte, 12)
if _, err := rand.Read(raw); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(raw)
}
func (s *chunkedUploadSession) chunkPath(index int) string {
return filepath.Join(s.Dir, fmt.Sprintf("chunk-%d", index))
}
func (s *chunkedUploadSession) hasAllChunks() bool {
for i := 0; i < s.ChunkCount; i++ {
info, err := os.Stat(s.chunkPath(i))
if err != nil || info.Size() != s.chunkSizeAt(i) {
return false
}
}
return true
}
// chunkSizeAt 返回第 index 片的期望字节数(末片按文件余量,其余固定 chunkSize)。
func (s *chunkedUploadSession) chunkSizeAt(index int) int64 {
if index == s.ChunkCount-1 {
rest := s.Size - int64(index)*chunkUploadChunkSize
if rest < 0 {
return 0
}
return rest
}
return chunkUploadChunkSize
}
func removeExpiredChunkSessions() {
now := time.Now()
chunkUploadSessions.Lock()
defer chunkUploadSessions.Unlock()
for id, sess := range chunkUploadSessions.m {
if now.Sub(sess.CreatedAt) > chunkUploadTTL && sess.mu.TryLock() {
sess.closed = true
if sess.release != nil {
if err := sess.release(); err != nil {
log.Printf("release expired upload session failed: %v", err)
}
}
_ = os.RemoveAll(sess.Dir)
delete(chunkUploadSessions.m, id)
sess.mu.Unlock()
}
}
}
func takeChunkSession(id string) *chunkedUploadSession {
removeExpiredChunkSessions()
chunkUploadSessions.Lock()
defer chunkUploadSessions.Unlock()
return chunkUploadSessions.m[id]
}
func dropChunkSession(id string) {
chunkUploadSessions.Lock()
defer chunkUploadSessions.Unlock()
if sess := chunkUploadSessions.m[id]; sess != nil {
// Caller holds sess.mu. Expiry uses TryLock to avoid lock inversion.
sess.closed = true
if sess.release != nil {
if err := sess.release(); err != nil {
log.Printf("release upload session failed: %v", err)
}
}
_ = os.RemoveAll(sess.Dir)
delete(chunkUploadSessions.m, id)
}
}
// RegisterChunkedUploadRoutes 注册本地媒体分片上传三条接口(POST 开始 / PUT 上传片 / POST 合并)。
func RegisterChunkedUploadRoutes(r *gin.RouterGroup, svc *service.Service) {
r.POST("/resources/uploads", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
policy, available := loadRuntimePolicy(c, svc)
if !available || !enforceRateLimit(c, "resources-upload:"+user.ID, policy.Request.ResourceUploadPerMinute, time.Minute) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, chunkUploadBodyCapJSON)
var req struct {
FileName string `json:"fileName"`
Kind string `json:"kind"`
Size int64 `json:"size"`
Width int `json:"width"`
Height int `json:"height"`
DurationMs int64 `json:"durationMs"`
IdempotencyKey string `json:"idempotencyKey"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
if req.FileName == "" || len(req.FileName) > 255 {
fail(c, http.StatusBadRequest, fmt.Errorf("文件名不能为空且不能超过 255 个字符"))
return
}
if req.Size <= 0 {
fail(c, http.StatusBadRequest, fmt.Errorf("文件大小必须大于 0"))
return
}
removeExpiredChunkSessions()
id, createdAt := newUploadSessionID(), time.Now()
if err := svc.ReserveChunkUploadSession(user.ID, id, req.Size, createdAt.Add(chunkUploadTTL), chunkUploadMaxPerUser); err != nil {
failService(c, err)
return
}
release := func() error { return svc.ReleaseChunkUploadSession(user.ID, id) }
dir, err := os.MkdirTemp("", "canvas-chunk-upload-*")
if err != nil {
if releaseErr := release(); releaseErr != nil {
log.Printf("release upload session failed: %v", releaseErr)
}
failService(c, err)
return
}
session := &chunkedUploadSession{
ID: id,
release: release,
UserID: user.ID,
FileName: req.FileName,
Kind: req.Kind,
Size: req.Size,
Width: req.Width,
Height: req.Height,
DurationMs: req.DurationMs,
IdempotencyKey: req.IdempotencyKey,
ChunkCount: int((req.Size + chunkUploadChunkSize - 1) / chunkUploadChunkSize),
Dir: dir,
CreatedAt: createdAt,
}
chunkUploadSessions.Lock()
chunkUploadSessions.m[session.ID] = session
chunkUploadSessions.Unlock()
ok(c, gin.H{"uploadId": session.ID, "chunkSize": chunkUploadChunkSize, "chunkCount": session.ChunkCount})
})
r.PUT("/resources/uploads/:id/chunks/:index", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
session := takeChunkSession(c.Param("id"))
if session == nil || session.UserID != user.ID {
fail(c, http.StatusNotFound, fmt.Errorf("上传会话不存在或已过期,请重新导入"))
return
}
index, err := strconv.Atoi(c.Param("index"))
session.mu.Lock()
defer session.mu.Unlock()
if session.closed || time.Since(session.CreatedAt) >= chunkUploadTTL {
fail(c, http.StatusNotFound, fmt.Errorf("上传会话不存在或已过期,请重新导入"))
return
}
if err != nil || index < 0 || index >= session.ChunkCount {
fail(c, http.StatusBadRequest, fmt.Errorf("非法的分片序号"))
return
}
expected := session.chunkSizeAt(index)
if expected <= 0 {
fail(c, http.StatusBadRequest, fmt.Errorf("非法的分片序号"))
return
}
// 单片限长(期望长度 + 少量余量),超长直接中断,避免内存/磁盘被恶意占用。
body := http.MaxBytesReader(c.Writer, c.Request.Body, expected+chunkUploadSlackBytes)
dst, err := os.OpenFile(session.chunkPath(index), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
failService(c, err)
return
}
written, copyErr := io.CopyN(dst, body, expected)
closeErr := dst.Close()
var probe [1]byte
extra, readErr := body.Read(probe[:])
if copyErr != nil || closeErr != nil {
_ = os.Remove(session.chunkPath(index))
fail(c, http.StatusBadRequest, fmt.Errorf("分片 %d 上传不完整,请重试", index))
return
}
if readErr != io.EOF || extra > 0 {
_ = os.Remove(session.chunkPath(index))
fail(c, http.StatusBadRequest, fmt.Errorf("分片 %d 超过大小限制", index))
return
}
_ = written
ok(c, gin.H{"index": index})
})
r.POST("/resources/uploads/:id/complete", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
id := c.Param("id")
session := takeChunkSession(id)
if session == nil || session.UserID != user.ID {
fail(c, http.StatusNotFound, fmt.Errorf("上传会话不存在或已过期,请重新导入"))
return
}
session.mu.Lock()
defer session.mu.Unlock()
if session.closed || time.Since(session.CreatedAt) >= chunkUploadTTL {
fail(c, http.StatusNotFound, fmt.Errorf("上传会话不存在或已过期,请重新导入"))
return
}
if !session.hasAllChunks() {
fail(c, http.StatusBadRequest, fmt.Errorf("上传文件不完整,请重新导入"))
return
}
mergedPath := filepath.Join(session.Dir, "merged")
merged, err := os.OpenFile(mergedPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
failService(c, err)
return
}
total := int64(0)
for i := 0; i < session.ChunkCount; i++ {
part, openErr := os.Open(session.chunkPath(i))
if openErr != nil {
_ = merged.Close()
failService(c, openErr)
return
}
n, copyErr := io.Copy(merged, part)
_ = part.Close()
if copyErr != nil {
_ = merged.Close()
failService(c, copyErr)
return
}
total += n
}
if closeErr := merged.Close(); closeErr != nil {
failService(c, closeErr)
return
}
if total != session.Size {
dropChunkSession(id)
fail(c, http.StatusBadRequest, fmt.Errorf("上传文件不完整,请重新导入"))
return
}
fh, err := os.Open(mergedPath)
if err != nil {
failService(c, err)
return
}
defer fh.Close()
resource, svcErr := svc.UploadReservedResourceFile(user.ID, session.ID, session.FileName, session.Size, session.Kind, session.Width, session.Height, session.DurationMs, fh, session.IdempotencyKey)
// 无论成败都结束会话:失败时前端会整传重试,不需要保留残片。
dropChunkSession(id)
if svcErr != nil {
failService(c, svcErr)
return
}
ok(c, gin.H{"resource": resource})
})
}