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

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

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
package assets
import (
"net/http"
"time"
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/storage"
)
type AccessPurpose string
type ResourceVariant string
type DeliveryMode string
const (
PurposeDisplay AccessPurpose = "display"
PurposeCopy AccessPurpose = "copy"
PurposeDownload AccessPurpose = "download"
PurposeProcess AccessPurpose = "browser-process"
PurposeProvider AccessPurpose = "provider-input"
VariantOriginal ResourceVariant = "original"
VariantPlayback ResourceVariant = "playback"
DeliveryCDN DeliveryMode = "cdn"
DeliveryOrigin DeliveryMode = "origin"
DeliveryLocal DeliveryMode = "platform-local"
DeliveryProxy DeliveryMode = "platform-proxy"
)
// AccessOptions contains use intent, never a client-controlled authorization or transport override.
type AccessOptions struct {
Purpose AccessPurpose `json:"purpose"`
Variant ResourceVariant `json:"variant"`
ExpiresAt time.Time `json:"-"`
DescribeOnly bool `json:"-"`
}
type AccessRequest struct {
ResourceID string `json:"resourceId"`
AccessOptions
}
type ResourceAccess struct {
ResourceID string `json:"resourceId"`
RequestedVariant ResourceVariant `json:"requestedVariant"`
ActualVariant ResourceVariant `json:"actualVariant"`
URL string `json:"url"`
Delivery DeliveryMode `json:"delivery"`
IssuedAt time.Time `json:"issuedAt"`
ExpiresAt *time.Time `json:"expiresAt"`
RefreshAt time.Time `json:"refreshAt"`
Revision string `json:"revision"`
FallbackReason string `json:"fallbackReason,omitempty"`
}
func AccessError(status int, reason, message string) *kernel.AppError {
err := kernel.NewAppError(status, message)
err.Reason = kernel.ErrorReason(reason)
return err
}
func NormalizeAccessOptions(options AccessOptions, allowProvider bool) (AccessOptions, error) {
if options.Purpose == "" {
options.Purpose = PurposeDisplay
}
if options.Variant == "" {
options.Variant = VariantOriginal
}
switch options.Purpose {
case PurposeDisplay, PurposeCopy, PurposeDownload, PurposeProcess:
case PurposeProvider:
if !allowProvider {
return options, kernel.Forbidden("不允许请求模型输入凭据")
}
default:
return options, kernel.BadAuthRequest("资源访问用途无效")
}
if options.Variant != VariantOriginal && options.Variant != VariantPlayback {
return options, kernel.BadAuthRequest("资源变体无效")
}
if options.Purpose == PurposeCopy || options.Purpose == PurposeDownload || options.Purpose == PurposeProvider {
options.Variant = VariantOriginal
}
return options, nil
}
// ResolveAccess is the sole delivery policy. The caller must authorize resource ownership/scene first.
// Local URL signing is injected by the composition root; storage adapters never know application keys.
func ResolveAccess(resource *model.Resource, setting storage.Settings, options AccessOptions, now time.Time,
platformURL func(ResourceVariant, time.Time) (string, error)) (*ResourceAccess, error) {
options, err := NormalizeAccessOptions(options, true)
if err != nil {
return nil, err
}
if resource == nil {
return nil, kernel.NotFound("资源不存在")
}
if resource.Status != model.ResourceStatusReady {
return nil, AccessError(http.StatusConflict, "resource_not_ready", "资源尚未上传完成")
}
ttl := 5 * time.Minute
if options.Purpose == PurposeProvider {
ttl = 4 * time.Hour
}
expires := now.Add(ttl)
if !options.ExpiresAt.IsZero() && options.ExpiresAt.Before(expires) {
expires = options.ExpiresAt
}
if !expires.After(now) {
return nil, kernel.Forbidden("资源授权已过期")
}
access := &ResourceAccess{ResourceID: resource.ID, RequestedVariant: options.Variant, ActualVariant: VariantOriginal,
IssuedAt: now, ExpiresAt: &expires, RefreshAt: now.Add(expires.Sub(now) * 4 / 5), Revision: resource.ETag + ":" + resource.PlaybackStatus + ":" + storage.DeliveryRevision(setting)}
if options.Variant == VariantPlayback {
if resource.Provider == "local" && resource.PlaybackStatus == model.PlaybackStatusReady && resource.PlaybackObjectKey != "" {
access.ActualVariant = VariantPlayback
} else {
access.FallbackReason = "playback_not_ready"
}
}
if resource.Provider == "local" {
access.Delivery = DeliveryLocal
} else if storage.CDNEnabled(setting) {
access.URL, err = storage.SignCDNURL(setting, resource.ObjectKey, expires)
access.Delivery = DeliveryCDN
if setting.Delivery.CDNAuthMode == "public" {
access.ExpiresAt = nil
}
} else if setting.Delivery.RequireCDN {
return nil, AccessError(503, "resource_cdn_unconfigured", "CDN 访问鉴权未配置,请检查存储分发设置")
} else if storage.PublicOrigin(setting) {
access.URL, err = storage.SignedOSSObjectURL(setting, resource.ObjectKey, expires)
access.Delivery = DeliveryOrigin
if setting.CDNBaseURL != "" {
access.FallbackReason = "cdn_auth_unconfigured"
}
} else if setting.Delivery.AllowPrivateProxy {
access.Delivery = DeliveryProxy
access.FallbackReason = "private_origin"
} else {
return nil, AccessError(503, "resource_origin_private", "资源源站不可公网访问,请配置 CDN 或明确启用私网源站代理")
}
if err != nil {
failure := AccessError(503, "resource_signing_failed", "资源访问地址签发失败,请检查存储分发设置")
failure.Cause = err
return nil, failure
}
if access.Delivery == DeliveryLocal || access.Delivery == DeliveryProxy {
access.URL, err = platformURL(access.ActualVariant, expires)
if err != nil {
return nil, err
}
}
return access, nil
}
+143
View File
@@ -0,0 +1,143 @@
package assets
import (
"strings"
"testing"
"time"
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/storage"
)
func testReadyResource(provider string) *model.Resource {
return &model.Resource{
ID: "resource-1", UserID: "user-1", Provider: provider, ObjectKey: "users/user-1/image/test.png",
Status: model.ResourceStatusReady, MimeType: "image/png", ETag: "etag-1",
}
}
func testPlatformURL(variants *[]ResourceVariant) func(ResourceVariant, time.Time) (string, error) {
return func(variant ResourceVariant, _ time.Time) (string, error) {
*variants = append(*variants, variant)
return "https://canvas.example/api/resources/resource-1/file", nil
}
}
func TestResolveAccessPolicyMatrix(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
tests := []struct {
name string
resource *model.Resource
setting storage.Settings
options AccessOptions
wantMode DeliveryMode
wantReason string
wantURL string
wantErr string
}{
{
name: "local uses platform controlled URL",
resource: testReadyResource("local"),
options: AccessOptions{Purpose: PurposeDisplay},
wantMode: DeliveryLocal,
wantURL: "https://canvas.example/api/resources/resource-1/file",
},
{
name: "public CDN uses CDN URL",
resource: testReadyResource("aliyun"),
setting: storage.Settings{Provider: "aliyun", CDNBaseURL: "https://media.example.com", Delivery: storage.DeliverySettings{CDNAuthMode: "public"}},
options: AccessOptions{Purpose: PurposeCopy},
wantMode: DeliveryCDN,
wantURL: "https://media.example.com/users/user-1/image/test.png",
},
{
name: "CDN without supported auth falls back to public origin",
resource: testReadyResource("aliyun"),
setting: storage.Settings{Provider: "aliyun", Endpoint: "https://s3.amazonaws.com", CDNBaseURL: "https://media.example.com", AccessKeyID: "id", AccessKeySecret: "secret"},
options: AccessOptions{Purpose: PurposeDisplay},
wantMode: DeliveryOrigin, wantReason: "cdn_auth_unconfigured",
},
{
name: "require CDN rejects incomplete CDN auth",
resource: testReadyResource("aliyun"),
setting: storage.Settings{Provider: "aliyun", Endpoint: "https://s3.amazonaws.com", CDNBaseURL: "https://media.example.com", AccessKeyID: "id", AccessKeySecret: "secret", Delivery: storage.DeliverySettings{RequireCDN: true}},
options: AccessOptions{Purpose: PurposeDisplay},
wantErr: "resource_cdn_unconfigured",
},
{
name: "private origin requires explicit proxy",
resource: testReadyResource("aliyun"),
setting: storage.Settings{Provider: "aliyun", Endpoint: "http://storage.internal", Delivery: storage.DeliverySettings{AllowPrivateProxy: true}},
options: AccessOptions{Purpose: PurposeDisplay},
wantMode: DeliveryProxy, wantReason: "private_origin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var variants []ResourceVariant
access, err := ResolveAccess(tt.resource, tt.setting, tt.options, now, testPlatformURL(&variants))
if tt.wantErr != "" {
if err == nil || !strings.Contains(string(errorReason(err)), tt.wantErr) {
t.Fatalf("ResolveAccess() error = %v, want reason %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("ResolveAccess() error = %v", err)
}
if access.Delivery != tt.wantMode || access.FallbackReason != tt.wantReason {
t.Fatalf("ResolveAccess() = %#v, want mode=%q reason=%q", access, tt.wantMode, tt.wantReason)
}
if tt.wantURL != "" && access.URL != tt.wantURL {
t.Fatalf("ResolveAccess().URL = %q, want %q", access.URL, tt.wantURL)
}
})
}
}
func TestResolveAccessVariantAndExpiryContract(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resource := testReadyResource("local")
resource.Kind = "video"
resource.PlaybackStatus = model.PlaybackStatusReady
resource.PlaybackObjectKey = "playback/resource-1.mp4"
var variants []ResourceVariant
access, err := ResolveAccess(resource, storage.Settings{}, AccessOptions{Purpose: PurposeProcess, Variant: VariantPlayback, ExpiresAt: now.Add(90 * time.Second)}, now, testPlatformURL(&variants))
if err != nil {
t.Fatal(err)
}
if access.ActualVariant != VariantPlayback || access.ExpiresAt == nil || !access.ExpiresAt.Equal(now.Add(90*time.Second)) {
t.Fatalf("playback access = %#v", access)
}
if len(variants) != 1 || variants[0] != VariantPlayback {
t.Fatalf("platform URL variants = %#v, want playback", variants)
}
provider, err := ResolveAccess(resource, storage.Settings{}, AccessOptions{Purpose: PurposeProvider}, now, testPlatformURL(&variants))
if err != nil {
t.Fatal(err)
}
if provider.ExpiresAt == nil || !provider.ExpiresAt.Equal(now.Add(4*time.Hour)) {
t.Fatalf("provider access expiry = %v, want %v", provider.ExpiresAt, now.Add(4*time.Hour))
}
}
func TestResolveAccessRejectsPendingResource(t *testing.T) {
resource := testReadyResource("local")
resource.Status = model.ResourceStatusPending
_, err := ResolveAccess(resource, storage.Settings{}, AccessOptions{Purpose: PurposeDisplay}, time.Now(), func(ResourceVariant, time.Time) (string, error) {
return "", nil
})
if err == nil || !strings.Contains(string(errorReason(err)), "resource_not_ready") {
t.Fatalf("ResolveAccess() error = %v, want resource_not_ready", err)
}
}
func errorReason(err error) string {
if appErr, ok := err.(*kernel.AppError); ok {
return string(appErr.Reason)
}
return err.Error()
}
+3 -8
View File
@@ -15,13 +15,8 @@ type ResourceStream struct {
AcceptRanges string
}
type ResourceDeliveryOptions struct {
ForceDirect bool
ForceProxy bool
}
type ResourceDelivery struct {
Resource *model.Resource
Stream *ResourceStream
RedirectURL string
Resource *model.Resource
Stream *ResourceStream
Access *ResourceAccess
}
+1 -1
View File
@@ -8,7 +8,7 @@ func TestIDFromFileURL(t *testing.T) {
want string
}{
{"/api/resources/abc_123/file", "abc_123"},
{"https://host.example/api/resources/abc_123/file?proxy=1", "abc_123"},
{"https://host.example/api/resources/abc_123/file?variant=original", "abc_123"},
{"/api/projects/abc_123", ""},
{"", ""},
}