feat: 添加媒体播放功能,支持基于短时效token的播放入口及相关API接口
This commit is contained in:
@@ -489,6 +489,8 @@ func (s *content) AssetsByRole(ctx context.Context, tenantID, contentID int64, r
|
||||
Select(maTbl.ALL).
|
||||
Where(
|
||||
maTbl.TenantID.Eq(tenantID),
|
||||
maTbl.DeletedAt.IsNull(),
|
||||
maTbl.Status.Eq(consts.MediaAssetStatusReady),
|
||||
caTbl.TenantID.Eq(tenantID),
|
||||
caTbl.ContentID.Eq(contentID),
|
||||
caTbl.Role.Eq(role),
|
||||
|
||||
168
backend/app/services/media_delivery.go
Normal file
168
backend/app/services/media_delivery.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
provider_jwt "quyun/v2/providers/jwt"
|
||||
|
||||
"go.ipao.vip/atom"
|
||||
"go.ipao.vip/atom/container"
|
||||
"go.ipao.vip/atom/contracts"
|
||||
@@ -34,6 +36,17 @@ func Provide(opts ...opt.Option) error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func(
|
||||
jwt *provider_jwt.JWT,
|
||||
) (*mediaDelivery, error) {
|
||||
obj := &mediaDelivery{
|
||||
jwt: jwt,
|
||||
}
|
||||
|
||||
return obj, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func(
|
||||
db *gorm.DB,
|
||||
ledger *ledger,
|
||||
@@ -52,6 +65,7 @@ func Provide(opts ...opt.Option) error {
|
||||
db *gorm.DB,
|
||||
ledger *ledger,
|
||||
mediaAsset *mediaAsset,
|
||||
mediaDelivery *mediaDelivery,
|
||||
order *order,
|
||||
tenant *tenant,
|
||||
tenantJoin *tenantJoin,
|
||||
@@ -59,15 +73,16 @@ func Provide(opts ...opt.Option) error {
|
||||
user *user,
|
||||
) (contracts.Initial, error) {
|
||||
obj := &services{
|
||||
content: content,
|
||||
db: db,
|
||||
ledger: ledger,
|
||||
mediaAsset: mediaAsset,
|
||||
order: order,
|
||||
tenant: tenant,
|
||||
tenantJoin: tenantJoin,
|
||||
test: test,
|
||||
user: user,
|
||||
content: content,
|
||||
db: db,
|
||||
ledger: ledger,
|
||||
mediaAsset: mediaAsset,
|
||||
mediaDelivery: mediaDelivery,
|
||||
order: order,
|
||||
tenant: tenant,
|
||||
tenantJoin: tenantJoin,
|
||||
test: test,
|
||||
user: user,
|
||||
}
|
||||
if err := obj.Prepare(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -8,28 +8,30 @@ var _db *gorm.DB
|
||||
|
||||
// exported CamelCase Services
|
||||
var (
|
||||
Content *content
|
||||
Ledger *ledger
|
||||
MediaAsset *mediaAsset
|
||||
Order *order
|
||||
Tenant *tenant
|
||||
TenantJoin *tenantJoin
|
||||
Test *test
|
||||
User *user
|
||||
Content *content
|
||||
Ledger *ledger
|
||||
MediaAsset *mediaAsset
|
||||
MediaDelivery *mediaDelivery
|
||||
Order *order
|
||||
Tenant *tenant
|
||||
TenantJoin *tenantJoin
|
||||
Test *test
|
||||
User *user
|
||||
)
|
||||
|
||||
// @provider(model)
|
||||
type services struct {
|
||||
db *gorm.DB
|
||||
// define Services
|
||||
content *content
|
||||
ledger *ledger
|
||||
mediaAsset *mediaAsset
|
||||
order *order
|
||||
tenant *tenant
|
||||
tenantJoin *tenantJoin
|
||||
test *test
|
||||
user *user
|
||||
content *content
|
||||
ledger *ledger
|
||||
mediaAsset *mediaAsset
|
||||
mediaDelivery *mediaDelivery
|
||||
order *order
|
||||
tenant *tenant
|
||||
tenantJoin *tenantJoin
|
||||
test *test
|
||||
user *user
|
||||
}
|
||||
|
||||
func (svc *services) Prepare() error {
|
||||
@@ -39,6 +41,7 @@ func (svc *services) Prepare() error {
|
||||
Content = svc.content
|
||||
Ledger = svc.ledger
|
||||
MediaAsset = svc.mediaAsset
|
||||
MediaDelivery = svc.mediaDelivery
|
||||
Order = svc.order
|
||||
Tenant = svc.tenant
|
||||
TenantJoin = svc.tenantJoin
|
||||
|
||||
Reference in New Issue
Block a user