feat: 添加媒体播放功能,支持基于短时效token的播放入口及相关API接口

This commit is contained in:
2025-12-22 17:44:25 +08:00
parent 70bba28492
commit 335a546aab
18 changed files with 639 additions and 38 deletions

View File

@@ -0,0 +1,168 @@
package services
import (
"context"
"errors"
"time"
"quyun/v2/app/errorx"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
provider_jwt "quyun/v2/providers/jwt"
jwtlib "github.com/golang-jwt/jwt/v4"
log "github.com/sirupsen/logrus"
"go.ipao.vip/gen/types"
"gorm.io/gorm"
)
// mediaDelivery 负责“媒体播放 token -> 实际播放地址”的安全下发。
// 当前版本只返回短时效 token 与 play endpoint真实对象存储签名将在后续接入。
//
// @provider
type mediaDelivery struct {
jwt *provider_jwt.JWT
}
const defaultMediaPlayTokenTTL = 5 * time.Minute
type mediaPlayClaims struct {
TenantID int64 `json:"tenant_id"`
ContentID int64 `json:"content_id"`
AssetID int64 `json:"asset_id"`
Role consts.ContentAssetRole `json:"role"`
ViewerUserID int64 `json:"viewer_user_id,omitempty"`
jwtlib.RegisteredClaims
}
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")
}
if ttl <= 0 {
ttl = defaultMediaPlayTokenTTL
}
if now.IsZero() {
now = time.Now()
}
exp := now.Add(ttl).UTC()
claims := &mediaPlayClaims{
TenantID: tenantID,
ContentID: contentID,
AssetID: assetID,
Role: role,
ViewerUserID: viewerUserID,
RegisteredClaims: jwtlib.RegisteredClaims{
Issuer: "v2-media",
IssuedAt: jwtlib.NewNumericDate(now.UTC()),
NotBefore: jwtlib.NewNumericDate(now.Add(-10 * time.Second).UTC()),
ExpiresAt: jwtlib.NewNumericDate(exp),
},
}
token := jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims)
signed, err := token.SignedString(s.jwt.SigningKey)
if err != nil {
return "", nil, errorx.Wrap(err).WithMsg("sign play token failed")
}
return signed, &exp, nil
}
func (s *mediaDelivery) ParsePlayToken(tokenString string) (*mediaPlayClaims, error) {
token, err := jwtlib.ParseWithClaims(tokenString, &mediaPlayClaims{}, func(token *jwtlib.Token) (interface{}, error) {
if _, ok := token.Method.(*jwtlib.SigningMethodHMAC); !ok {
return nil, errorx.ErrSignatureInvalid.WithMsg("unexpected signing method")
}
return s.jwt.SigningKey, nil
})
if err != nil {
var ve *jwtlib.ValidationError
if errors.As(err, &ve) {
switch {
case ve.Errors&jwtlib.ValidationErrorExpired != 0:
return nil, errorx.ErrDataExpired.WithMsg("play token expired")
case ve.Errors&jwtlib.ValidationErrorNotValidYet != 0:
return nil, errorx.ErrPreconditionFailed.WithMsg("play token not active yet")
case ve.Errors&jwtlib.ValidationErrorMalformed != 0:
return nil, errorx.ErrInvalidParameter.WithMsg("play token malformed")
default:
return nil, errorx.ErrSignatureInvalid.WithMsg("play token invalid")
}
}
return nil, errorx.ErrSignatureInvalid.WithMsg("play token invalid")
}
claims, ok := token.Claims.(*mediaPlayClaims)
if !ok || !token.Valid || claims == nil {
return nil, errorx.ErrSignatureInvalid.WithMsg("play token invalid")
}
if claims.TenantID <= 0 || claims.ContentID <= 0 || claims.AssetID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("play token payload invalid")
}
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) {
if tenantID <= 0 {
return "", errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
}
claims, err := s.ParsePlayToken(token)
if err != nil {
return "", err
}
if claims.TenantID != tenantID {
return "", errorx.ErrForbidden.WithMsg("tenant mismatch")
}
log.WithFields(log.Fields{
"tenant_id": tenantID,
"content_id": claims.ContentID,
"asset_id": claims.AssetID,
"role": claims.Role,
"viewer_user_id": claims.ViewerUserID,
"exp": claims.ExpiresAt,
}).Info("services.media_delivery.resolve_play_redirect")
var asset models.MediaAsset
if err := _db.WithContext(ctx).
Where("tenant_id = ? AND id = ? AND deleted_at IS NULL", tenantID, claims.AssetID).
First(&asset).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", errorx.ErrRecordNotFound.WithMsg("media asset not found")
}
return "", err
}
if asset.Status != consts.MediaAssetStatusReady {
return "", errorx.ErrPreconditionFailed.WithMsg("media asset not ready")
}
// 二次校验token 必须对应“该内容 + 该角色”的绑定关系,避免 token 被滥用到非预期内容。
var ca models.ContentAsset
if err := _db.WithContext(ctx).
Where(
"tenant_id = ? AND content_id = ? AND asset_id = ? AND role = ?",
tenantID,
claims.ContentID,
claims.AssetID,
claims.Role,
).
First(&ca).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", errorx.ErrRecordNotFound.WithMsg("content asset binding not found")
}
return "", err
}
// 约束play endpoint 不返回 bucket/object_key仅负责重定向到“短时效地址”。
// 后续接入对象存储后,将在此处根据 provider 生成签名 URL。
switch asset.Provider {
case "stub":
return "", errorx.ErrServiceUnavailable.WithMsg("storage provider not configured")
default:
_ = types.JSON(asset.Meta) // keep meta referenced for future extensions
return "", errorx.ErrServiceUnavailable.WithMsg("storage provider not implemented: " + asset.Provider)
}
}