feat: 实现媒体播放功能,支持本地文件和重定向,添加相关测试用例
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
112
backend/app/services/media_delivery_test.go
Normal file
112
backend/app/services/media_delivery_test.go
Normal file
@@ -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")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user