From 9f182a29029fea4bf8949d2eb9f38b5b0eb3746c Mon Sep 17 00:00:00 2001
From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com>
Date: Sun, 16 Aug 2026 13:07:50 +0800
Subject: [PATCH] =?UTF-8?q?fix(media):=20=E5=AF=B9=E8=B1=A1=E5=AD=98?=
=?UTF-8?q?=E5=82=A8=20CDN=20-=20=E7=BB=9F=E4=B8=80=E8=B5=84=E6=BA=90?=
=?UTF-8?q?=E9=A2=84=E8=A7=88=E4=B8=8E=E4=B8=8B=E8=BD=BD=E8=B5=B0=20CDN?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
统一登录资源、公开画布分享和管理后台媒体的 CDN 预览与下载出口,并保留同源代理读取能力。
---
CHANGELOG.md | 2 +
README.md | 2 +-
backend/internal/handler/auth.go | 10 +-
backend/internal/handler/canvas_share.go | 12 +-
backend/internal/handler/user_data.go | 19 ++-
backend/internal/model/models_project.go | 2 +-
backend/internal/service/analytics.go | 41 ++++--
backend/internal/service/auth.go | 4 +
backend/internal/service/canvas_share.go | 37 +++++-
backend/internal/service/resource.go | 74 +++++++++++
backend/internal/service/resource_test.go | 117 ++++++++++++++++++
.../layout/user-oss-settings-form.tsx | 2 +-
web/src/services/api/resources.ts | 2 +-
13 files changed, 296 insertions(+), 28 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9acda36a..0b281347d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+- 修复任务中心等资源入口仍由应用服务器转发对象存储媒体的问题:配置 CDN 加速域名后,登录资源、公开画布分享和管理员媒体预览统一重定向到 CDN;内部 Blob 读取保留同源代理兼容。
+
## v1.0.59
- 画布媒体工作流支持批量连线、失败重试与模型能力增强。
diff --git a/README.md b/README.md
index 09649be8c..c3a02594f 100644
--- a/README.md
+++ b/README.md
@@ -258,7 +258,7 @@ docker compose -f docker-compose.local.yml up -d --build
- 用户自定义 AI API Key 保存在浏览器本地;登录态拉取模型目录时会临时提交给自部署后端但不会保存,创建异步任务时会加密入队;仅应使用可信部署,生产环境必须启用 HTTPS。
- 画布和素材登录后同步到后端,本地 `localForage` 继续承担缓存和降级存储。
-- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;两种对象存储均可选填 CDN 加速域名,让上传继续使用 Endpoint、下载与预览使用 CDN。CDN 地址不携带 OSS/COS 签名,敏感资源必须在 CDN 侧配置 URL 鉴权;删除业务记录不会自动清理远端对象。
+- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;两种对象存储均可选填 CDN 加速域名。上传继续使用 Endpoint,配置 CDN 后登录资源、公开画布分享及管理后台媒体的下载与预览会在权限校验后统一重定向到 CDN;需要同源 Blob 的内部读取仍由后端代理,但后端同样通过 CDN 取源。CDN 地址不携带 OSS/COS 签名,敏感资源必须在 CDN 侧配置 URL 鉴权,并为需要浏览器跨域读取的场景配置 CORS;删除业务记录不会自动清理远端对象。
- 用户主动上传、Agent 会话附件和 AI 生成资源的单文件上限、账号容量及 UTC 日上传总量由后台“资源与策略”统一维护,默认分别为 50MB、32MB、64MB、2GB 和 200MB;管理员可按可信部署需要调整,单文件业务上限最高 999MB,Nginx 请求体硬上限为 1024MB。
## 公网部署安全
diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go
index 794cd1c48..3d4565c29 100644
--- a/backend/internal/handler/auth.go
+++ b/backend/internal/handler/auth.go
@@ -590,11 +590,19 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
failService(c, err)
return
}
- stream, err := svc.OpenAdminAPICallLogMediaRange(user, c.Param("id"), c.GetHeader("Range"))
+ delivery, err := svc.PrepareAdminAPICallLogMediaDelivery(user, c.Param("id"), c.GetHeader("Range"))
if err != nil {
failService(c, err)
return
}
+ if delivery.RedirectURL != "" {
+ c.Header("Cache-Control", "private, no-store")
+ c.Header("Referrer-Policy", "no-referrer")
+ c.Header("X-Content-Type-Options", "nosniff")
+ c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL)
+ return
+ }
+ stream := delivery.Stream
defer stream.Body.Close()
resource := stream.Resource
mimeType := resource.MimeType
diff --git a/backend/internal/handler/canvas_share.go b/backend/internal/handler/canvas_share.go
index 832eb473b..23e6b38eb 100644
--- a/backend/internal/handler/canvas_share.go
+++ b/backend/internal/handler/canvas_share.go
@@ -78,11 +78,21 @@ func RegisterCanvasShareRoutes(r *gin.RouterGroup, svc *service.Service) {
if !enforceRateLimit(c, "public-canvas-resource:"+c.ClientIP(), 300, time.Minute) {
return
}
- stream, err := svc.OpenSharedCanvasResourceRange(c.Param("token"), c.Param("resourceId"), c.GetHeader("Range"))
+ delivery, err := svc.PrepareSharedCanvasResourceDelivery(c.Param("token"), c.Param("resourceId"), c.GetHeader("Range"))
if err != nil {
fail(c, http.StatusNotFound, errors.New("分享资源不存在"))
return
}
+ if delivery.RedirectURL != "" {
+ c.Header("Cache-Control", "no-store")
+ c.Header("Content-Security-Policy", "sandbox")
+ c.Header("Referrer-Policy", "no-referrer")
+ c.Header("X-Content-Type-Options", "nosniff")
+ c.Header("X-Robots-Tag", "noindex, nofollow")
+ c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL)
+ return
+ }
+ stream := delivery.Stream
defer stream.Body.Close()
resource := stream.Resource
mimeType := resource.MimeType
diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go
index 4a4818cef..295f270c5 100644
--- a/backend/internal/handler/user_data.go
+++ b/backend/internal/handler/user_data.go
@@ -214,24 +214,23 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
failService(c, err)
return
}
- resource, err := svc.Resource(user.ID, c.Param("id"))
+ delivery, err := svc.PrepareResourceDelivery(user.ID, c.Param("id"), service.ResourceDeliveryOptions{
+ ForceDirect: c.Query("direct") == "1",
+ ForceProxy: c.Query("proxy") == "1",
+ })
if err != nil {
- fail(c, http.StatusNotFound, err)
+ failService(c, err)
return
}
- if c.Query("direct") == "1" && resource.Provider != "local" {
- directURL, err := svc.DirectResourceURL(user.ID, resource.ID)
- if err != nil {
- failService(c, err)
- return
- }
- // 签名 URL 不进入应用、代理或浏览器缓存,也不作为后续请求的 Referer 泄露。
+ if delivery.RedirectURL != "" {
+ // CDN 或对象存储直连地址不进入应用缓存,也不作为后续请求的 Referer 泄露。
c.Header("Cache-Control", "private, no-store")
c.Header("Referrer-Policy", "no-referrer")
c.Header("X-Content-Type-Options", "nosniff")
- c.Redirect(http.StatusTemporaryRedirect, directURL)
+ c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL)
return
}
+ resource := delivery.Resource
etag := resourceResponseETag(resource)
// 私有资源允许浏览器保存响应,但每次复用前必须重新鉴权;304 会在读取 OSS 前返回。
c.Header("Cache-Control", "private, no-cache")
diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go
index 91b8170c6..1b19fc963 100644
--- a/backend/internal/model/models_project.go
+++ b/backend/internal/model/models_project.go
@@ -12,7 +12,7 @@ type Resource struct {
Provider string `json:"provider" gorm:"size:24"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket" gorm:"size:160"`
- // 用户 OSS 每次修改都会生成新版本,资源固定引用创建时的版本,避免历史资源因换密钥失效。
+ // 用户 OSS 每次修改都会生成新版本,资源固定引用创建时的存储与密钥;同一存储位置的 CDN 域名可跟随当前配置。
StorageSettingID string `json:"-" gorm:"index;size:36"`
ObjectKey string `json:"objectKey" gorm:"index"`
PublicURL string `json:"publicUrl"`
diff --git a/backend/internal/service/analytics.go b/backend/internal/service/analytics.go
index e3d4bb25d..581a099c4 100644
--- a/backend/internal/service/analytics.go
+++ b/backend/internal/service/analytics.go
@@ -280,33 +280,58 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error {
// 管理员媒体读取必须同时校验日志、任务和资源归属,不能绕过用户资源边界按资源 ID 任意读取。
func (s *Service) OpenAdminAPICallLogMediaRange(actor *model.User, logID string, rangeHeader string) (*ResourceStream, error) {
- if err := s.RequireAdmin(actor); err != nil {
+ userID, resource, err := s.adminAPICallLogMediaResource(actor, logID)
+ if err != nil {
return nil, err
}
+ return s.openResourceRange(userID, resource, rangeHeader)
+}
+
+func (s *Service) PrepareAdminAPICallLogMediaDelivery(actor *model.User, logID string, rangeHeader string) (*ResourceDelivery, error) {
+ userID, resource, err := s.adminAPICallLogMediaResource(actor, logID)
+ if err != nil {
+ return nil, err
+ }
+ delivery, err := s.prepareResourceDelivery(userID, resource, ResourceDeliveryOptions{})
+ if err != nil || delivery.RedirectURL != "" {
+ return delivery, err
+ }
+ stream, err := s.openResourceRange(userID, resource, rangeHeader)
+ if err != nil {
+ return nil, err
+ }
+ delivery.Stream = stream
+ return delivery, nil
+}
+
+func (s *Service) adminAPICallLogMediaResource(actor *model.User, logID string) (string, *model.Resource, error) {
+ if err := s.RequireAdmin(actor); err != nil {
+ return "", nil, err
+ }
log, err := s.repo.APICallLog(strings.TrimSpace(logID))
if err != nil {
- return nil, err
+ return "", nil, err
}
if log.TaskID == "" || (log.Capability != "image" && log.Capability != "video") {
- return nil, BadAuthRequest("该请求没有可预览媒体")
+ return "", nil, BadAuthRequest("该请求没有可预览媒体")
}
task, err := s.repo.Task(log.TaskID)
if err != nil {
- return nil, err
+ return "", nil, err
}
if task.UserID != log.UserID {
- return nil, BadAuthRequest("请求与媒体归属不一致")
+ return "", nil, BadAuthRequest("请求与媒体归属不一致")
}
previewURL, _ := taskMediaPreview(task.ResultJSON, task.Type)
resourceID := canvasResourceID(previewURL)
if resourceID == "" {
- return nil, BadAuthRequest("该请求没有已持久化媒体")
+ return "", nil, BadAuthRequest("该请求没有已持久化媒体")
}
resource, err := s.repo.ResourceForUser(log.UserID, resourceID)
if err != nil {
- return nil, err
+ return "", nil, err
}
- return s.openResourceRange(log.UserID, resource, rangeHeader)
+ return log.UserID, resource, nil
}
func (s *Service) AdminAPICallLog(actor *model.User, id string) (*model.ApiCallLog, error) {
diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go
index f7b15d7e2..310f6779c 100644
--- a/backend/internal/service/auth.go
+++ b/backend/internal/service/auth.go
@@ -70,6 +70,10 @@ func BadAuthRequest(message string) *AuthError {
return &AuthError{Status: 400, Message: message}
}
+func NotFound(message string) *AuthError {
+ return &AuthError{Status: 404, Message: message}
+}
+
func Unauthorized(message string) *AuthError {
return &AuthError{Status: 401, Message: message}
}
diff --git a/backend/internal/service/canvas_share.go b/backend/internal/service/canvas_share.go
index 0fdc88ac7..d42d7ae6d 100644
--- a/backend/internal/service/canvas_share.go
+++ b/backend/internal/service/canvas_share.go
@@ -137,18 +137,47 @@ func (s *Service) OpenSharedCanvasResource(token string, resourceID string) (*mo
}
func (s *Service) OpenSharedCanvasResourceRange(token string, resourceID string, rangeHeader string) (*ResourceStream, error) {
- share, project, err := s.sharedCanvasProject(token)
+ userID, resource, err := s.sharedCanvasResource(token, resourceID)
if err != nil {
return nil, err
}
+ return s.openResourceRange(userID, resource, rangeHeader)
+}
+
+func (s *Service) PrepareSharedCanvasResourceDelivery(token string, resourceID string, rangeHeader string) (*ResourceDelivery, error) {
+ userID, resource, err := s.sharedCanvasResource(token, resourceID)
+ if err != nil {
+ return nil, err
+ }
+ delivery, err := s.prepareResourceDelivery(userID, resource, ResourceDeliveryOptions{})
+ if err != nil || delivery.RedirectURL != "" {
+ return delivery, err
+ }
+ stream, err := s.openResourceRange(userID, resource, rangeHeader)
+ if err != nil {
+ return nil, err
+ }
+ delivery.Stream = stream
+ return delivery, nil
+}
+
+func (s *Service) sharedCanvasResource(token string, resourceID string) (string, *model.Resource, error) {
+ share, project, err := s.sharedCanvasProject(token)
+ if err != nil {
+ return "", nil, err
+ }
_, allowedResources, err := publicCanvasProject(project, token)
if err != nil {
- return nil, err
+ return "", nil, err
}
if !allowedResources[resourceID] {
- return nil, gorm.ErrRecordNotFound
+ return "", nil, gorm.ErrRecordNotFound
}
- return s.OpenResourceRange(share.UserID, resourceID, rangeHeader)
+ resource, err := s.repo.ResourceForUser(share.UserID, resourceID)
+ if err != nil {
+ return "", nil, err
+ }
+ return share.UserID, resource, nil
}
func (s *Service) sharedCanvasProject(token string) (*model.CanvasShare, *model.CanvasProject, error) {
diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go
index ce7af6a95..4552b3bab 100644
--- a/backend/internal/service/resource.go
+++ b/backend/internal/service/resource.go
@@ -29,6 +29,7 @@ import (
"infinite-canvas/backend/internal/model"
cos "github.com/tencentyun/cos-go-sdk-v5"
+ "gorm.io/gorm"
)
const providerResourceURLTTL = 4 * time.Hour
@@ -45,6 +46,17 @@ type ResourceStream struct {
AcceptRanges string
}
+type ResourceDeliveryOptions struct {
+ ForceDirect bool
+ ForceProxy bool
+}
+
+type ResourceDelivery struct {
+ Resource *model.Resource
+ Stream *ResourceStream
+ RedirectURL string
+}
+
func (s *Service) Resources(userID string, limit int) ([]model.Resource, error) {
resources, err := s.repo.Resources(userID, limit)
for index := range resources {
@@ -90,6 +102,48 @@ func (s *Service) directResourceURL(resource *model.Resource, expiresAt time.Tim
return signedOSSObjectURL(setting, resource.ObjectKey, expiresAt)
}
+// PrepareResourceDelivery 统一决定浏览器资源出口:配置 CDN 时默认直连 CDN,显式代理仅用于需要同源 Blob 的内部读取。
+func (s *Service) PrepareResourceDelivery(userID string, id string, options ResourceDeliveryOptions) (*ResourceDelivery, error) {
+ resource, err := s.repo.ResourceForUser(userID, id)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, NotFound("资源不存在")
+ }
+ return nil, err
+ }
+ return s.prepareResourceDelivery(userID, resource, options)
+}
+
+func (s *Service) prepareResourceDelivery(userID string, resource *model.Resource, options ResourceDeliveryOptions) (*ResourceDelivery, error) {
+ if resource == nil {
+ return nil, errors.New("资源不存在")
+ }
+ if resource.Status != model.ResourceStatusReady {
+ return nil, BadAuthRequest("资源尚未上传完成")
+ }
+ if resource.Provider != "local" && !options.ForceProxy {
+ setting, err := s.ossSettingForResource(userID, resource)
+ if err != nil {
+ return nil, err
+ }
+ if setting.CDNBaseURL != "" {
+ redirectURL, err := ossCDNObjectURL(setting.CDNBaseURL, resource.ObjectKey)
+ if err != nil {
+ return nil, err
+ }
+ return &ResourceDelivery{Resource: resource, RedirectURL: redirectURL}, nil
+ }
+ if options.ForceDirect {
+ redirectURL, err := signedOSSObjectURL(setting, resource.ObjectKey, time.Now().Add(directResourceURLTTL))
+ if err != nil {
+ return nil, err
+ }
+ return &ResourceDelivery{Resource: resource, RedirectURL: redirectURL}, nil
+ }
+ }
+ return &ResourceDelivery{Resource: resource}, nil
+}
+
func (s *Service) signedPublicResourceURL(resourceID string, expiresAt time.Time) (string, error) {
baseURL, err := s.publicResourceBaseURL()
if err != nil {
@@ -634,6 +688,16 @@ func (s *Service) ossSettingForResource(userID string, resource *model.Resource)
var err error
if resource.StorageSettingID != "" {
_, setting, err = s.readUserOSSSettingByID(userID, resource.StorageSettingID)
+ if err == nil {
+ _, current, currentErr := s.readUserOSSSetting(userID)
+ if currentErr != nil {
+ return ossSettingValue{}, currentErr
+ }
+ // 密钥继续固定在历史版本;同一存储位置的 CDN 域名跟随当前配置,使已有资源也立即切换。
+ if resourceStorageMatches(current, resource) {
+ setting.CDNBaseURL = current.CDNBaseURL
+ }
+ }
} else {
_, setting, err = s.readOSSSetting()
}
@@ -652,6 +716,16 @@ func (s *Service) ossSettingForResource(userID string, resource *model.Resource)
return setting, nil
}
+func resourceStorageMatches(setting ossSettingValue, resource *model.Resource) bool {
+ if resource == nil {
+ return false
+ }
+ setting = normalizeOSSSetting(setting)
+ return setting.Provider == strings.ToLower(strings.TrimSpace(resource.Provider)) &&
+ setting.Endpoint == strings.TrimRight(strings.TrimSpace(resource.Endpoint), "/") &&
+ setting.Bucket == strings.TrimSpace(resource.Bucket)
+}
+
func ossSettingForProvider(setting ossSettingValue, provider string) (ossSettingValue, error) {
setting = normalizeOSSSetting(setting)
provider = strings.ToLower(strings.TrimSpace(provider))
diff --git a/backend/internal/service/resource_test.go b/backend/internal/service/resource_test.go
index 771d902d7..8532bcc25 100644
--- a/backend/internal/service/resource_test.go
+++ b/backend/internal/service/resource_test.go
@@ -358,6 +358,123 @@ func TestDirectResourceURLChecksOwnershipAndSignsOSSResource(t *testing.T) {
}
}
+func TestPrepareResourceDeliveryPrefersConfiguredCDN(t *testing.T) {
+ svc := newResourceTestService(t)
+ settingJSON, _ := json.Marshal(ossSettingValue{
+ Enabled: true, Provider: tencentCOSProvider, Endpoint: "https://cos.ap-shanghai.myqcloud.com", CDNBaseURL: "https://media.example.com",
+ Bucket: "private-bucket-1250000000", AccessKeyID: "secret-id", AccessKeySecret: "secret-key",
+ })
+ if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil {
+ t.Fatal(err)
+ }
+ resource := model.Resource{
+ ID: "resource-cdn-delivery", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady,
+ Provider: tencentCOSProvider, Endpoint: "https://cos.ap-shanghai.myqcloud.com", Bucket: "private-bucket-1250000000",
+ ObjectKey: "users/user-1/image/test image.png", MimeType: "image/png",
+ }
+ if err := svc.repo.CreateResource(&resource); err != nil {
+ t.Fatal(err)
+ }
+ delivery, err := svc.PrepareResourceDelivery("user-1", resource.ID, ResourceDeliveryOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if delivery.Resource == nil || delivery.Resource.ID != resource.ID || delivery.RedirectURL != "https://media.example.com/users/user-1/image/test%20image.png" {
+ t.Fatalf("PrepareResourceDelivery() = %#v", delivery)
+ }
+}
+
+func TestPrepareResourceDeliveryAllowsExplicitProxyWithCDN(t *testing.T) {
+ svc := newResourceTestService(t)
+ settingJSON, _ := json.Marshal(ossSettingValue{
+ Enabled: true, Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", CDNBaseURL: "https://media.example.com",
+ Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value",
+ })
+ if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil {
+ t.Fatal(err)
+ }
+ resource := model.Resource{
+ ID: "resource-cdn-proxy", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady,
+ Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", Bucket: "private-bucket",
+ ObjectKey: "users/user-1/image/proxy.png", MimeType: "image/png",
+ }
+ if err := svc.repo.CreateResource(&resource); err != nil {
+ t.Fatal(err)
+ }
+ delivery, err := svc.PrepareResourceDelivery("user-1", resource.ID, ResourceDeliveryOptions{ForceProxy: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if delivery.Resource == nil || delivery.Resource.ID != resource.ID || delivery.RedirectURL != "" {
+ t.Fatalf("PrepareResourceDelivery(force proxy) = %#v", delivery)
+ }
+}
+
+func TestCurrentUserCDNSettingAppliesToHistoricalResourcesInSameStorage(t *testing.T) {
+ t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
+ server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ defer server.Close()
+ svc := newResourceTestService(t)
+ actor := &model.User{ID: "user-1"}
+ if _, err := svc.UpdateUserOSSSetting(actor, OSSSettingRequest{
+ Enabled: true, Provider: aliyunOSSProvider, Endpoint: server.URL, Bucket: "private-bucket",
+ AccessKeyID: "access-id", AccessKeySecret: "secret-value",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ historical, _, err := svc.readUserOSSSetting(actor.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resource := model.Resource{
+ ID: "resource-user-cdn", UserID: actor.ID, Kind: "image", Status: model.ResourceStatusReady,
+ Provider: aliyunOSSProvider, Endpoint: server.URL, Bucket: "private-bucket", StorageSettingID: historical.ID,
+ ObjectKey: "users/user-1/image/historical.png", MimeType: "image/png",
+ }
+ if err := svc.repo.CreateResource(&resource); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.UpdateUserOSSSetting(actor, OSSSettingRequest{
+ Enabled: true, Provider: aliyunOSSProvider, Endpoint: server.URL, CDNBaseURL: server.URL, Bucket: "private-bucket",
+ AccessKeyID: "access-id", AccessKeySecret: "secret-value",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ delivery, err := svc.PrepareResourceDelivery(actor.ID, resource.ID, ResourceDeliveryOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if delivery.RedirectURL != server.URL+"/users/user-1/image/historical.png" {
+ t.Fatalf("PrepareResourceDelivery(historical user resource) = %#v", delivery)
+ }
+}
+
+func TestPrepareResourceDeliveryKeepsForcedOriginDirectWithoutCDN(t *testing.T) {
+ svc := newResourceTestService(t)
+ settingJSON, _ := json.Marshal(ossSettingValue{
+ Enabled: true, Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", Bucket: "private-bucket",
+ AccessKeyID: "access-id", AccessKeySecret: "secret-value",
+ })
+ if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil {
+ t.Fatal(err)
+ }
+ resource := model.Resource{
+ ID: "resource-origin-direct", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady,
+ Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", Bucket: "private-bucket",
+ ObjectKey: "users/user-1/image/direct.png", MimeType: "image/png",
+ }
+ if err := svc.repo.CreateResource(&resource); err != nil {
+ t.Fatal(err)
+ }
+ delivery, err := svc.PrepareResourceDelivery("user-1", resource.ID, ResourceDeliveryOptions{ForceDirect: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(delivery.RedirectURL, "private-bucket.oss-cn-test.aliyuncs.com/users/user-1/image/direct.png") || !strings.Contains(delivery.RedirectURL, "Signature=") {
+ t.Fatalf("PrepareResourceDelivery(force direct) = %#v", delivery)
+ }
+}
+
func TestNormalizeSingleByteRange(t *testing.T) {
tests := map[string]string{
"bytes=0-1023": "bytes=0-1023",
diff --git a/web/src/components/layout/user-oss-settings-form.tsx b/web/src/components/layout/user-oss-settings-form.tsx
index 5b18c3c3b..3b2f7a2ae 100644
--- a/web/src/components/layout/user-oss-settings-form.tsx
+++ b/web/src/components/layout/user-oss-settings-form.tsx
@@ -85,7 +85,7 @@ export function UserOSSSettingsForm() {
支持阿里云 OSS 与腾讯云 COS。启用后,新上传和新生成的媒体优先写入你的存储桶;停用时回退到平台存储。历史资源固定使用创建时的配置版本。
+支持阿里云 OSS 与腾讯云 COS。启用后,新上传和新生成的媒体优先写入你的存储桶;停用时回退到平台存储。历史资源继续使用创建时的存储与密钥配置,同一存储位置的 CDN 域名会跟随当前配置。