From d70a33e4f99c48e49b6ac405528213a752334de1 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 23 Dec 2025 12:57:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=96=87=E4=BB=B6=E5=92=8C=E9=87=8D=E5=AE=9A?= =?UTF-8?q?=E5=90=91=EF=BC=8C=E6=B7=BB=E5=8A=A0=E7=9B=B8=E5=85=B3=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/http/tenant_media/play.go | 16 ++- backend/app/services/media_asset.go | 2 +- backend/app/services/media_delivery.go | 138 +++++++++++++++++--- backend/app/services/media_delivery_test.go | 112 ++++++++++++++++ 4 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 backend/app/services/media_delivery_test.go diff --git a/backend/app/http/tenant_media/play.go b/backend/app/http/tenant_media/play.go index 8754e62..09811f0 100644 --- a/backend/app/http/tenant_media/play.go +++ b/backend/app/http/tenant_media/play.go @@ -1,6 +1,7 @@ package tenant_media import ( + "quyun/v2/app/errorx" "quyun/v2/app/services" "quyun/v2/database/models" @@ -30,9 +31,20 @@ func (*media) play(ctx fiber.Ctx, tenant *models.Tenant, token string) error { "tenant_id": tenant.ID, }).Info("tenant_media.play") - location, err := services.MediaDelivery.ResolvePlayRedirect(ctx.Context(), tenant.ID, token) + res, err := services.MediaDelivery.ResolvePlay(ctx.Context(), tenant.ID, token) if err != nil { return err } - return ctx.Redirect().To(location) + + switch res.Kind { + case services.MediaPlayResolutionKindLocalFile: + if res.ContentType != "" { + ctx.Set("Content-Type", res.ContentType) + } + return ctx.SendFile(res.LocalFilePath) + case services.MediaPlayResolutionKindRedirect: + return ctx.Redirect().To(res.RedirectURL) + default: + return errorx.ErrServiceUnavailable.WithMsg("unsupported play resolution") + } } diff --git a/backend/app/services/media_asset.go b/backend/app/services/media_asset.go index b6f7eda..f45988e 100644 --- a/backend/app/services/media_asset.go +++ b/backend/app/services/media_asset.go @@ -170,7 +170,7 @@ func (s *mediaAsset) AdminUploadInit(ctx context.Context, tenantID, operatorUser UserID: operatorUserID, Type: typ, Status: consts.MediaAssetStatusUploaded, - Provider: "stub", + Provider: "local", Bucket: "", ObjectKey: objectKey, Meta: types.JSON(metaBytes), diff --git a/backend/app/services/media_delivery.go b/backend/app/services/media_delivery.go index 9c963d5..14aaffe 100644 --- a/backend/app/services/media_delivery.go +++ b/backend/app/services/media_delivery.go @@ -2,7 +2,11 @@ package services import ( "context" + "encoding/json" "errors" + "os" + "path/filepath" + "strings" "time" "quyun/v2/app/errorx" @@ -34,6 +38,27 @@ type mediaPlayClaims struct { jwtlib.RegisteredClaims } +type MediaPlayResolutionKind string + +const ( + MediaPlayResolutionKindRedirect MediaPlayResolutionKind = "redirect" + MediaPlayResolutionKindLocalFile MediaPlayResolutionKind = "local_file" +) + +type MediaPlayResolution struct { + Kind MediaPlayResolutionKind + + RedirectURL string + + LocalFilePath string + ContentType string +} + +const ( + defaultLocalMediaRoot = "var/media" + envLocalMediaRoot = "MEDIA_LOCAL_ROOT" +) + func (s *mediaDelivery) CreatePlayToken(tenantID, contentID, assetID int64, role consts.ContentAssetRole, viewerUserID int64, ttl time.Duration, now time.Time) (string, *time.Time, error) { if tenantID <= 0 || contentID <= 0 || assetID <= 0 { return "", nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/content_id/asset_id must be > 0") @@ -102,18 +127,66 @@ func (s *mediaDelivery) ParsePlayToken(tokenString string) (*mediaPlayClaims, er return claims, nil } -// ResolvePlayRedirect validates the token and resolves it to an actual playable URL. -// 当前未接入对象存储签名:若 provider=stub 则返回 ServiceUnavailable。 -func (s *mediaDelivery) ResolvePlayRedirect(ctx context.Context, tenantID int64, token string) (string, error) { +func localMediaRoot() string { + if v := strings.TrimSpace(os.Getenv(envLocalMediaRoot)); v != "" { + return v + } + return defaultLocalMediaRoot +} + +func localMediaFilePath(root, objectKey string) (string, error) { + root = strings.TrimSpace(root) + if root == "" { + return "", errorx.ErrInternalError.WithMsg("local media root is empty") + } + if strings.TrimSpace(objectKey) == "" { + return "", errorx.ErrInternalError.WithMsg("object_key is empty") + } + if filepath.IsAbs(objectKey) { + return "", errorx.ErrForbidden.WithMsg("invalid object_key") + } + cleanKey := filepath.Clean(objectKey) + if cleanKey == "." || strings.HasPrefix(cleanKey, ".."+string(filepath.Separator)) || cleanKey == ".." { + return "", errorx.ErrForbidden.WithMsg("invalid object_key") + } + + rootClean := filepath.Clean(root) + full := filepath.Join(rootClean, cleanKey) + rel, err := filepath.Rel(rootClean, full) + if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { + return "", errorx.ErrForbidden.WithMsg("invalid object_key") + } + return full, nil +} + +func contentTypeFromAsset(asset *models.MediaAsset) string { + if asset == nil || len(asset.Meta) == 0 { + return "" + } + // 尽量从 meta.content_type 读取,避免本地文件无扩展名导致错误推断。 + // meta 的具体结构不稳定,因此只做最佳努力解析。 + var m map[string]any + _ = json.Unmarshal(asset.Meta, &m) + if v, ok := m["content_type"]; ok { + if s, ok := v.(string); ok { + return strings.TrimSpace(s) + } + } + return "" +} + +// ResolvePlay resolves a play token to a redirect URL or a local file to serve. +// C1(local): 当 provider=local 时返回本地文件路径(不暴露 object_key)。 +func (s *mediaDelivery) ResolvePlay(ctx context.Context, tenantID int64, token string) (*MediaPlayResolution, error) { if tenantID <= 0 { - return "", errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") } claims, err := s.ParsePlayToken(token) if err != nil { - return "", err + return nil, err } if claims.TenantID != tenantID { - return "", errorx.ErrForbidden.WithMsg("tenant mismatch") + return nil, errorx.ErrForbidden.WithMsg("tenant mismatch") } log.WithFields(log.Fields{ @@ -133,12 +206,12 @@ func (s *mediaDelivery) ResolvePlayRedirect(ctx context.Context, tenantID int64, ).First() if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return "", errorx.ErrRecordNotFound.WithMsg("media asset not found") + return nil, errorx.ErrRecordNotFound.WithMsg("media asset not found") } - return "", err + return nil, err } if asset.Status != consts.MediaAssetStatusReady { - return "", errorx.ErrPreconditionFailed.WithMsg("media asset not ready") + return nil, errorx.ErrPreconditionFailed.WithMsg("media asset not ready") } // 二次校验:token 必须对应“该内容 + 该角色”的绑定关系,避免 token 被滥用到非预期内容。 @@ -150,17 +223,52 @@ func (s *mediaDelivery) ResolvePlayRedirect(ctx context.Context, tenantID int64, tblCA.Role.Eq(claims.Role), ).First(); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return "", errorx.ErrRecordNotFound.WithMsg("content asset binding not found") + return nil, errorx.ErrRecordNotFound.WithMsg("content asset binding not found") } - return "", err + return nil, err } - // 约束:play endpoint 不返回 bucket/object_key,仅负责重定向到“短时效地址”。 - // 后续接入对象存储后,将在此处根据 provider 生成签名 URL。 + // 约束:play endpoint 不返回 bucket/object_key: + // - local: 直接下发本地文件(由 /media/play 进行 sendfile),不暴露路径结构; + // - remote: 返回短时效签名 URL(后续接入)。 switch asset.Provider { + case "local": + path, err := localMediaFilePath(localMediaRoot(), asset.ObjectKey) + if err != nil { + return nil, err + } + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, errorx.ErrRecordNotFound.WithMsg("media file not found") + } + return nil, errorx.Wrap(err).WithMsg("stat media file failed") + } + + ct := contentTypeFromAsset(asset) + if ct == "" { + ct = "application/octet-stream" + } + + return &MediaPlayResolution{ + Kind: MediaPlayResolutionKindLocalFile, + LocalFilePath: path, + ContentType: ct, + }, nil case "stub": - return "", errorx.ErrServiceUnavailable.WithMsg("storage provider not configured") + return nil, errorx.ErrServiceUnavailable.WithMsg("storage provider not configured") default: - return "", errorx.ErrServiceUnavailable.WithMsg("storage provider not implemented: " + asset.Provider) + return nil, errorx.ErrServiceUnavailable.WithMsg("storage provider not implemented: " + asset.Provider) } } + +// ResolvePlayRedirect is kept for compatibility with earlier code paths. +func (s *mediaDelivery) ResolvePlayRedirect(ctx context.Context, tenantID int64, token string) (string, error) { + res, err := s.ResolvePlay(ctx, tenantID, token) + if err != nil { + return "", err + } + if res.Kind != MediaPlayResolutionKindRedirect || strings.TrimSpace(res.RedirectURL) == "" { + return "", errorx.ErrServiceUnavailable.WithMsg("play redirect not available") + } + return res.RedirectURL, nil +} diff --git a/backend/app/services/media_delivery_test.go b/backend/app/services/media_delivery_test.go new file mode 100644 index 0000000..ef787fa --- /dev/null +++ b/backend/app/services/media_delivery_test.go @@ -0,0 +1,112 @@ +package services + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + "time" + + "quyun/v2/app/commands/testx" + "quyun/v2/database" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/suite" + + _ "go.ipao.vip/atom" + "go.ipao.vip/atom/contracts" + "go.ipao.vip/gen/types" + "go.uber.org/dig" +) + +type MediaDeliveryTestSuiteInjectParams struct { + dig.In + + DB *sql.DB + Initials []contracts.Initial `group:"initials"` // nolint:structcheck +} + +type MediaDeliveryTestSuite struct { + suite.Suite + MediaDeliveryTestSuiteInjectParams +} + +func Test_MediaDelivery(t *testing.T) { + providers := testx.Default().With(Provide) + + testx.Serve(providers, t, func(p MediaDeliveryTestSuiteInjectParams) { + suite.Run(t, &MediaDeliveryTestSuite{MediaDeliveryTestSuiteInjectParams: p}) + }) +} + +func (s *MediaDeliveryTestSuite) Test_ResolvePlay_LocalFile() { + Convey("MediaDelivery.ResolvePlay local provider", s.T(), func() { + ctx := s.T().Context() + now := time.Now().UTC() + tenantID := int64(1) + viewerUserID := int64(2) + + database.Truncate(ctx, s.DB, models.TableNameContentAsset, models.TableNameMediaAsset, models.TableNameContent) + + content := &models.Content{ + TenantID: tenantID, + UserID: viewerUserID, + Title: "t", + Description: "", + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityPublic, + PreviewSeconds: 60, + PreviewDownloadable: false, + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + So(content.Create(ctx), ShouldBeNil) + + objectKey := "tenants/1/users/2/video/test.bin" + asset := &models.MediaAsset{ + TenantID: tenantID, + UserID: viewerUserID, + Type: consts.MediaAssetTypeVideo, + Status: consts.MediaAssetStatusReady, + Provider: "local", + Bucket: "", + ObjectKey: objectKey, + Meta: types.JSON([]byte(`{"content_type":"application/octet-stream"}`)), + CreatedAt: now, + UpdatedAt: now, + } + So(asset.Create(ctx), ShouldBeNil) + + binding := &models.ContentAsset{ + TenantID: tenantID, + UserID: viewerUserID, + ContentID: content.ID, + AssetID: asset.ID, + Role: consts.ContentAssetRolePreview, + Sort: 1, + CreatedAt: now, + UpdatedAt: now, + } + So(binding.Create(ctx), ShouldBeNil) + + root := s.T().TempDir() + So(os.Setenv(envLocalMediaRoot, root), ShouldBeNil) + defer os.Unsetenv(envLocalMediaRoot) + + fullPath := filepath.Join(root, filepath.FromSlash(objectKey)) + So(os.MkdirAll(filepath.Dir(fullPath), 0o755), ShouldBeNil) + So(os.WriteFile(fullPath, []byte("hello"), 0o644), ShouldBeNil) + + token, _, err := MediaDelivery.CreatePlayToken(tenantID, content.ID, asset.ID, consts.ContentAssetRolePreview, viewerUserID, 0, now) + So(err, ShouldBeNil) + + res, err := MediaDelivery.ResolvePlay(ctx, tenantID, token) + So(err, ShouldBeNil) + So(res.Kind, ShouldEqual, MediaPlayResolutionKindLocalFile) + So(res.LocalFilePath, ShouldEqual, fullPath) + So(res.ContentType, ShouldEqual, "application/octet-stream") + }) +}