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

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