diff --git a/backend/internal/app/provider.go b/backend/internal/app/provider.go index b98376cdb..e8a7f9d6c 100644 --- a/backend/internal/app/provider.go +++ b/backend/internal/app/provider.go @@ -805,7 +805,7 @@ func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, poli } useObjectURL := policy.requireURL || (policy.preferURL && resourceUsesObjectStorage(resource)) if useObjectURL { - signedURL, err := s.providerResourceURL(resource, time.Now().Add(providerResourceURLTTL)) + signedURL, err := s.providerResourceURL(resource, time.Time{}) if err != nil { return fmt.Errorf("生成参考素材地址失败:%w", err) } diff --git a/backend/internal/app/provider_resource_helpers.go b/backend/internal/app/provider_resource_helpers.go index e9c0f6812..1fe2edc8d 100644 --- a/backend/internal/app/provider_resource_helpers.go +++ b/backend/internal/app/provider_resource_helpers.go @@ -18,6 +18,7 @@ func (s *Service) ResourceForUser(actor *model.User, id string) (*model.Resource // providerResourceURL 只能接收已经完成用户归属和 ready 状态校验的资源, // 并为上游签发短时地址;它不是绕过权限校验的通用资源 URL 生成器。 +// expiresAt 为零值时使用后台「厂商访问地址有效期」(默认 4 小时),非零时只能进一步缩短。 func (s *Service) providerResourceURL(resource *model.Resource, expiresAt time.Time) (string, error) { access, err := s.resolveResourceAccess(resource, ResourceAccessOptions{Purpose: assets.PurposeProvider, ExpiresAt: expiresAt}) if err != nil { diff --git a/backend/internal/app/resource.go b/backend/internal/app/resource.go index 287744efe..ee64d2f52 100644 --- a/backend/internal/app/resource.go +++ b/backend/internal/app/resource.go @@ -32,8 +32,6 @@ import ( "gorm.io/gorm" ) -const providerResourceURLTTL = 4 * time.Hour - var errInvalidGeneratedDataURL = errors.New("生成内容 data URL 无效") type ResourceStream = assets.ResourceStream @@ -95,10 +93,18 @@ func (s *Service) publicResourceBaseURL() (*url.URL, error) { return validatePublicResourceBaseURL(raw) } +// 服务器访问地址是本服务对外暴露的根地址,由管理员自行决定, +// 允许本机、局域网或任意主机;这里只校验格式,不套用出站私网策略。 func validatePublicResourceBaseURL(raw string) (*url.URL, error) { - parsed, err := ValidateOutboundURL(raw) - if err != nil { - return nil, err + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Hostname() == "" { + return nil, BadAuthRequest("服务器访问地址必须是完整的 http/https 地址") + } + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return nil, BadAuthRequest("服务器访问地址只支持 http/https") + } + if parsed.User != nil { + return nil, BadAuthRequest("服务器访问地址不能包含认证信息") } if parsed.RawQuery != "" || parsed.Fragment != "" { return nil, BadAuthRequest("服务器访问地址不能包含查询参数或片段") diff --git a/backend/internal/app/resource_access.go b/backend/internal/app/resource_access.go index cbf155eb9..e1a965f6e 100644 --- a/backend/internal/app/resource_access.go +++ b/backend/internal/app/resource_access.go @@ -70,6 +70,10 @@ func (s *Service) resolveResourceAccess(resource *model.Resource, options Resour if err != nil { return nil, WrapAppError(503, "无法解析资源实际存储位置", err) } + setting = s.storageSettingWithRuntimePolicy(setting) + } else { + // 本地资源无需存储配置,只注入运行时策略,让平台签名地址同样遵循后台配置的有效期。 + setting.Runtime = s.storageSettingWithRuntimePolicy(ossSettingValue{}).Runtime } return assets.ResolveAccess(resource, setting, options, time.Now().UTC(), func(variant assets.ResourceVariant, expires time.Time) (string, error) { return s.signedResourceAccessURL(resource, variant, expires, options.Purpose == assets.PurposeProvider) @@ -134,9 +138,6 @@ func (s *Service) signedResourceAccessURL(resource *model.Resource, variant asse if err != nil { return "", err } - if base.Scheme != "https" { - return "", BadAuthRequest("模型读取平台资源需要配置 HTTPS 公网访问地址") - } return base.ResolveReference(u).String(), nil } diff --git a/backend/internal/assets/access.go b/backend/internal/assets/access.go index 83c2f8a81..f382a349f 100644 --- a/backend/internal/assets/access.go +++ b/backend/internal/assets/access.go @@ -102,9 +102,11 @@ func ResolveAccess(resource *model.Resource, setting storage.Settings, options A if resource.Status != model.ResourceStatusReady { return nil, AccessError(http.StatusConflict, "resource_not_ready", "资源尚未上传完成") } - ttl := 5 * time.Minute + // 有效期来自后台「资源与请求策略」;未注入运行时策略时回落到存储层默认值(均为 4 小时)。 + runtime := storage.NormalizeSettings(storage.Settings{Runtime: setting.Runtime}).Runtime + ttl := runtime.AccessURLTTL if options.Purpose == PurposeProvider { - ttl = 4 * time.Hour + ttl = runtime.ProviderAccessURLTTL } expires := now.Add(ttl) if !options.ExpiresAt.IsZero() && options.ExpiresAt.Before(expires) { diff --git a/backend/internal/assets/access_test.go b/backend/internal/assets/access_test.go index bf3205451..912412a1c 100644 --- a/backend/internal/assets/access_test.go +++ b/backend/internal/assets/access_test.go @@ -185,3 +185,19 @@ func errorReason(err error) string { } return err.Error() } + +func TestResolveAccessUsesRuntimePolicyTTL(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + resource := testReadyResource("local") + setting := storage.Settings{Runtime: storage.RuntimePolicy{AccessURLTTL: 2 * time.Hour, ProviderAccessURLTTL: 6 * time.Hour}} + var variants []ResourceVariant + for purpose, want := range map[AccessPurpose]time.Duration{PurposeDisplay: 2 * time.Hour, PurposeProvider: 6 * time.Hour} { + access, err := ResolveAccess(resource, setting, AccessOptions{Purpose: purpose}, now, testPlatformURL(&variants)) + if err != nil { + t.Fatal(err) + } + if access.ExpiresAt == nil || !access.ExpiresAt.Equal(now.Add(want)) { + t.Fatalf("%s access expiry = %v, want %v", purpose, access.ExpiresAt, now.Add(want)) + } + } +} diff --git a/backend/internal/platform/runtime_policy.go b/backend/internal/platform/runtime_policy.go index 1d2af3750..ebe683a61 100644 --- a/backend/internal/platform/runtime_policy.go +++ b/backend/internal/platform/runtime_policy.go @@ -161,7 +161,7 @@ func DefaultRuntimePolicy() RuntimePolicySetting { }, Storage: RuntimeStoragePolicy{ TransferTimeoutSeconds: 120, - AccessURLTTLSeconds: 300, + AccessURLTTLSeconds: 14_400, ProviderAccessURLTTLSeconds: 14_400, NonSeekableBufferMB: 64, ErrorBodyKB: 1, diff --git a/backend/internal/storage/settings.go b/backend/internal/storage/settings.go index 3a130657c..a0bbfccf8 100644 --- a/backend/internal/storage/settings.go +++ b/backend/internal/storage/settings.go @@ -11,7 +11,7 @@ const ( qiniuKodoProvider = "qiniu" s3Provider = "s3" defaultOSSPathPrefix = "open-ai-canvas" - resourceAccessURLTTL = 5 * time.Minute + resourceAccessURLTTL = 4 * time.Hour ) type Settings struct { diff --git a/web/src/pages/admin/settings/storage-settings-page.tsx b/web/src/pages/admin/settings/storage-settings-page.tsx index 03748b05b..45c8384e2 100644 --- a/web/src/pages/admin/settings/storage-settings-page.tsx +++ b/web/src/pages/admin/settings/storage-settings-page.tsx @@ -462,7 +462,7 @@ export default function StorageSettingsPage() {
} title="公开访问根地址" description="用于生成本地资源的短时签名链接;填写站点根地址,不要附带 /api、查询参数或片段。" />
- +
} />