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