feat: 实现媒体播放功能,支持本地文件和重定向,添加相关测试用例

This commit is contained in:
2025-12-23 12:57:11 +08:00
parent 1dba706022
commit d70a33e4f9
4 changed files with 250 additions and 18 deletions

View File

@@ -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")
}
}

View File

@@ -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),

View File

@@ -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
}

View 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")
})
}