Files
quyun-v2/backend/app/services/media_delivery.go
Rogee 2cc823d3a8 feat: Introduce MediaAssetVariant for better asset management
- Added MediaAssetVariant enum with values 'main' and 'preview'.
- Updated media asset service logic to utilize MediaAssetVariant for variant handling.
- Refactored database models and queries to include variant and source_asset_id fields.
- Enhanced validation for asset variants in upload and processing functions.
- Updated Swagger documentation to reflect new variant structure and descriptions.
- Implemented necessary database migrations to support the new variant constraints.
2025-12-22 19:27:31 +08:00

167 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
"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")
tblAsset, queryAsset := models.MediaAssetQuery.QueryContext(ctx)
asset, err := queryAsset.Where(
tblAsset.TenantID.Eq(tenantID),
tblAsset.ID.Eq(claims.AssetID),
tblAsset.DeletedAt.IsNull(),
).First()
if 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 被滥用到非预期内容。
tblCA, queryCA := models.ContentAssetQuery.QueryContext(ctx)
if _, err := queryCA.Where(
tblCA.TenantID.Eq(tenantID),
tblCA.ContentID.Eq(claims.ContentID),
tblCA.AssetID.Eq(claims.AssetID),
tblCA.Role.Eq(claims.Role),
).First(); 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:
return "", errorx.ErrServiceUnavailable.WithMsg("storage provider not implemented: " + asset.Provider)
}
}