refactor(media): 资源分发 - 统一资源访问策略与云存储重定向

This commit is contained in:
ddCat
2026-09-22 21:41:00 +08:00
parent af6633047c
commit 487c34bfeb
50 changed files with 2112 additions and 1266 deletions

View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
@@ -18,6 +17,25 @@ import (
)
func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
r.POST("/resources/access", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
var req []service.ResourceAccessRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
result, err := svc.ResourceAccessBatch(user.ID, req)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"items": result})
})
r.POST("/assets/batch-delete", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
@@ -277,154 +295,28 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, gin.H{"resource": resource})
})
r.GET("/resources/:id/oss-url", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
resource, err := svc.Resource(user.ID, c.Param("id"))
if err != nil {
fail(c, http.StatusNotFound, err)
return
}
ossURL, err := svc.DirectResourceURL(user.ID, resource.ID)
if err != nil {
failService(c, err)
return
}
// 签名地址只用于当前复制动作,禁止浏览器或中间代理缓存。
c.Header("Cache-Control", "private, no-store")
c.Header("Referrer-Policy", "no-referrer")
ok(c, gin.H{"url": ossURL})
})
r.GET("/resources/:id/file", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
delivery, err := svc.PrepareResourceDelivery(user.ID, c.Param("id"), service.ResourceDeliveryOptions{
ForceDirect: c.Query("direct") == "1",
ForceProxy: c.Query("proxy") == "1",
})
options := resourceAccessOptions(c)
delivery, err := svc.PrepareResourceDelivery(user.ID, c.Param("id"), options, c.GetHeader("Range"))
if err != nil {
failService(c, err)
return
}
if delivery.RedirectURL != "" {
// 签名链接仅有效 5 分钟;重定向缓存必须短于签名 TTL,避免命中过期地址。
c.Header("Cache-Control", "private, max-age=240")
c.Header("Referrer-Policy", "no-referrer")
c.Header("X-Content-Type-Options", "nosniff")
c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL)
return
}
resource := delivery.Resource
etag := resourceResponseETag(resource)
// variant=playback:serve 浏览器兼容播放副本(H.265→H.264 转码)。
// 副本就绪时用独立 ETag 后缀,避免浏览器拿原件缓存命中 304 而继续黑屏。
usePlayback := c.Query("variant") == "playback" && resource.Provider == "local" &&
resource.PlaybackStatus == model.PlaybackStatusReady && resource.PlaybackObjectKey != ""
serveETag := etag
if usePlayback {
serveETag = etag + ":pb"
}
// 资源 ID 内容不可变(上传永远生成新 ID,不会原地覆盖):图片可以放心交给浏览器
// 磁盘强缓存 30 天,大画布二次打开零请求直读磁盘缓存。视频/音频涉及转码副本
// 就绪与 Range 语义,保持逐次条件请求(304)。
if strings.HasPrefix(resource.MimeType, "image/") {
c.Header("Cache-Control", "private, max-age=2592000, stale-while-revalidate=86400")
} else {
c.Header("Cache-Control", "private, no-cache")
}
c.Header("ETag", serveETag)
c.Header("Accept-Ranges", "bytes")
c.Header("X-Content-Type-Options", "nosniff")
if resource.Kind == "file" {
c.Header("Content-Disposition", "attachment")
c.Header("Content-Security-Policy", "sandbox")
}
if ifNoneMatch(c.GetHeader("If-None-Match"), serveETag) {
c.Status(http.StatusNotModified)
return
}
rangeHeader := c.GetHeader("Range")
if ifRange := strings.TrimSpace(c.GetHeader("If-Range")); ifRange != "" && ifRange != serveETag {
rangeHeader = ""
}
var stream *service.ResourceStream
if usePlayback {
stream, err = svc.OpenResourcePlaybackRange(user.ID, resource.ID)
if err == nil {
resource = stream.Resource // MimeType 已置 video/mp4
} else if errors.Is(err, service.ErrPlaybackNotReady) {
// 副本尚未就绪:回退原件,并撤销 :pb 后缀,保证副本就绪后
// 浏览器不会拿原件缓存命中 304 而继续黑屏。
c.Header("ETag", etag)
stream, err = svc.OpenResourceRange(user.ID, resource.ID, rangeHeader)
if err != nil {
failService(c, err)
return
}
} else {
failService(c, err)
return
}
} else {
stream, err = svc.OpenResourceRange(user.ID, resource.ID, rangeHeader)
if err != nil {
failService(c, err)
return
}
}
if err != nil {
failService(c, err)
return
}
defer stream.Body.Close()
if resource.MimeType == "" {
resource.MimeType = "application/octet-stream"
}
if resource.Provider == "local" {
if seeker, ok := stream.Body.(io.ReadSeeker); ok {
c.Header("Content-Type", resource.MimeType)
http.ServeContent(c.Writer, c.Request, resource.ID, resource.UpdatedAt, seeker)
return
}
}
if stream.ContentRange != "" {
c.Header("Content-Range", stream.ContentRange)
}
if stream.AcceptRanges != "" {
c.Header("Accept-Ranges", stream.AcceptRanges)
}
c.DataFromReader(stream.StatusCode, stream.ContentLength, resource.MimeType, stream.Body, nil)
serveResourceDelivery(c, delivery, "private, no-cache", "")
})
publicResourceHandler := func(c *gin.Context) {
stream, err := svc.OpenPublicResourceRange(c.Param("id"), c.Query("expires"), c.Query("signature"), c.GetHeader("Range"))
options := resourceAccessOptions(c)
delivery, err := svc.PreparePublicResourceDelivery(c.Param("id"), c.Query("expires"), c.Query("signature"), options, c.GetHeader("Range"))
if err != nil {
failService(c, err)
return
}
defer stream.Body.Close()
resource := stream.Resource
if resource.MimeType == "" {
resource.MimeType = "application/octet-stream"
}
c.Header("Cache-Control", "public, max-age=0, must-revalidate")
c.Header("Accept-Ranges", "bytes")
c.Header("Referrer-Policy", "no-referrer")
c.Header("X-Content-Type-Options", "nosniff")
if stream.ContentRange != "" {
c.Header("Content-Range", stream.ContentRange)
}
if seeker, ok := stream.Body.(io.ReadSeeker); ok {
c.Header("Content-Type", resource.MimeType)
http.ServeContent(c.Writer, c.Request, resource.ID, resource.UpdatedAt, seeker)
return
}
c.DataFromReader(stream.StatusCode, stream.ContentLength, resource.MimeType, stream.Body, nil)
serveResourceDelivery(c, delivery, "public, max-age=0, must-revalidate", "")
}
r.GET("/public/resources/:id/file", publicResourceHandler)
r.GET("/public/resources/:id/file/:filename", publicResourceHandler)