feat: 添加媒体播放功能,支持基于短时效token的播放入口及相关API接口
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
"quyun/v2/app/http/super"
|
"quyun/v2/app/http/super"
|
||||||
"quyun/v2/app/http/tenant"
|
"quyun/v2/app/http/tenant"
|
||||||
"quyun/v2/app/http/tenant_join"
|
"quyun/v2/app/http/tenant_join"
|
||||||
|
"quyun/v2/app/http/tenant_media"
|
||||||
"quyun/v2/app/http/tenant_public"
|
"quyun/v2/app/http/tenant_public"
|
||||||
"quyun/v2/app/jobs"
|
"quyun/v2/app/jobs"
|
||||||
"quyun/v2/app/middlewares"
|
"quyun/v2/app/middlewares"
|
||||||
@@ -57,6 +58,7 @@ func Command() atom.Option {
|
|||||||
tenant.Provide,
|
tenant.Provide,
|
||||||
tenant_join.Provide,
|
tenant_join.Provide,
|
||||||
tenant_public.Provide,
|
tenant_public.Provide,
|
||||||
|
tenant_media.Provide,
|
||||||
// {Provider: api.Provide},
|
// {Provider: api.Provide},
|
||||||
// {Provider: web.Provide},
|
// {Provider: web.Provide},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package tenant
|
package tenant
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
"quyun/v2/app/errorx"
|
"quyun/v2/app/errorx"
|
||||||
"quyun/v2/app/http/tenant/dto"
|
"quyun/v2/app/http/tenant/dto"
|
||||||
"quyun/v2/app/requests"
|
"quyun/v2/app/requests"
|
||||||
@@ -104,13 +108,32 @@ func (*content) previewAssets(ctx fiber.Ctx, tenant *models.Tenant, user *models
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playables := make([]*dto.ContentPlayableAsset, 0, len(assets))
|
||||||
|
for _, asset := range assets {
|
||||||
|
token, expiresAt, err := services.MediaDelivery.CreatePlayToken(tenant.ID, contentID, asset.ID, consts.ContentAssetRolePreview, user.ID, 0, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var meta json.RawMessage
|
||||||
|
if len(asset.Meta) > 0 {
|
||||||
|
meta = json.RawMessage(asset.Meta)
|
||||||
|
}
|
||||||
|
playables = append(playables, &dto.ContentPlayableAsset{
|
||||||
|
AssetID: asset.ID,
|
||||||
|
Type: asset.Type,
|
||||||
|
PlayURL: "/t/" + tenant.Code + "/v1/media/play?token=" + url.QueryEscape(token),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
Meta: meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
previewSeconds := int32(detail.Content.PreviewSeconds)
|
previewSeconds := int32(detail.Content.PreviewSeconds)
|
||||||
if previewSeconds <= 0 {
|
if previewSeconds <= 0 {
|
||||||
previewSeconds = consts.DefaultContentPreviewSeconds
|
previewSeconds = consts.DefaultContentPreviewSeconds
|
||||||
}
|
}
|
||||||
return &dto.ContentAssetsResponse{
|
return &dto.ContentAssetsResponse{
|
||||||
Content: detail.Content,
|
Content: detail.Content,
|
||||||
Assets: assets,
|
Assets: playables,
|
||||||
PreviewSeconds: previewSeconds,
|
PreviewSeconds: previewSeconds,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -150,8 +173,27 @@ func (*content) mainAssets(ctx fiber.Ctx, tenant *models.Tenant, user *models.Us
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playables := make([]*dto.ContentPlayableAsset, 0, len(assets))
|
||||||
|
for _, asset := range assets {
|
||||||
|
token, expiresAt, err := services.MediaDelivery.CreatePlayToken(tenant.ID, contentID, asset.ID, consts.ContentAssetRoleMain, user.ID, 0, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var meta json.RawMessage
|
||||||
|
if len(asset.Meta) > 0 {
|
||||||
|
meta = json.RawMessage(asset.Meta)
|
||||||
|
}
|
||||||
|
playables = append(playables, &dto.ContentPlayableAsset{
|
||||||
|
AssetID: asset.ID,
|
||||||
|
Type: asset.Type,
|
||||||
|
PlayURL: "/t/" + tenant.Code + "/v1/media/play?token=" + url.QueryEscape(token),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
Meta: meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return &dto.ContentAssetsResponse{
|
return &dto.ContentAssetsResponse{
|
||||||
Content: detail.Content,
|
Content: detail.Content,
|
||||||
Assets: assets,
|
Assets: playables,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ type ContentDetail struct {
|
|||||||
type ContentAssetsResponse struct {
|
type ContentAssetsResponse struct {
|
||||||
// Content is the content entity.
|
// Content is the content entity.
|
||||||
Content *models.Content `json:"content,omitempty"`
|
Content *models.Content `json:"content,omitempty"`
|
||||||
// Assets is the list of media assets for the requested role (preview/main).
|
// Assets is the list of playable assets for the requested role (preview/main).
|
||||||
Assets []*models.MediaAsset `json:"assets,omitempty"`
|
Assets []*ContentPlayableAsset `json:"assets,omitempty"`
|
||||||
// PreviewSeconds indicates the max preview duration (only meaningful for preview response).
|
// PreviewSeconds indicates the max preview duration (only meaningful for preview response).
|
||||||
PreviewSeconds int32 `json:"preview_seconds,omitempty"`
|
PreviewSeconds int32 `json:"preview_seconds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
23
backend/app/http/tenant/dto/content_asset_play.go
Normal file
23
backend/app/http/tenant/dto/content_asset_play.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"quyun/v2/pkg/consts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContentPlayableAsset is a deliverable media asset item with short-lived play URL/token.
|
||||||
|
type ContentPlayableAsset struct {
|
||||||
|
AssetID int64 `json:"asset_id"`
|
||||||
|
Type consts.MediaAssetType `json:"type"`
|
||||||
|
|
||||||
|
// PlayURL is a short-lived URL; do NOT expose bucket/object_key directly.
|
||||||
|
PlayURL string `json:"play_url"`
|
||||||
|
|
||||||
|
// ExpiresAt indicates when PlayURL/token expires; optional.
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
|
||||||
|
// Meta is a display-safe whitelist (currently passthrough JSON); optional.
|
||||||
|
Meta json.RawMessage `json:"meta,omitempty"`
|
||||||
|
}
|
||||||
38
backend/app/http/tenant_media/play.go
Normal file
38
backend/app/http/tenant_media/play.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package tenant_media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"quyun/v2/app/services"
|
||||||
|
"quyun/v2/database/models"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// media provides media play endpoints (token-based, no JWT required).
|
||||||
|
//
|
||||||
|
// @provider
|
||||||
|
type media struct{}
|
||||||
|
|
||||||
|
// play
|
||||||
|
//
|
||||||
|
// @Summary 媒体播放入口(短时效 token)
|
||||||
|
// @Tags TenantMedia
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param tenantCode path string true "Tenant Code"
|
||||||
|
// @Param token query string true "Play token"
|
||||||
|
//
|
||||||
|
// @Router /t/:tenantCode/v1/media/play [get]
|
||||||
|
// @Bind tenant local key(tenant)
|
||||||
|
// @Bind token query
|
||||||
|
func (*media) play(ctx fiber.Ctx, tenant *models.Tenant, token string) error {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"tenant_id": tenant.ID,
|
||||||
|
}).Info("tenant_media.play")
|
||||||
|
|
||||||
|
location, err := services.MediaDelivery.ResolvePlayRedirect(ctx.Context(), tenant.ID, token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Redirect().To(location)
|
||||||
|
}
|
||||||
37
backend/app/http/tenant_media/provider.gen.go
Executable file
37
backend/app/http/tenant_media/provider.gen.go
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
package tenant_media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"quyun/v2/app/middlewares"
|
||||||
|
|
||||||
|
"go.ipao.vip/atom"
|
||||||
|
"go.ipao.vip/atom/container"
|
||||||
|
"go.ipao.vip/atom/contracts"
|
||||||
|
"go.ipao.vip/atom/opt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Provide(opts ...opt.Option) error {
|
||||||
|
if err := container.Container.Provide(func() (*media, error) {
|
||||||
|
obj := &media{}
|
||||||
|
|
||||||
|
return obj, nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := container.Container.Provide(func(
|
||||||
|
media *media,
|
||||||
|
middlewares *middlewares.Middlewares,
|
||||||
|
) (contracts.HttpRoute, error) {
|
||||||
|
obj := &Routes{
|
||||||
|
media: media,
|
||||||
|
middlewares: middlewares,
|
||||||
|
}
|
||||||
|
if err := obj.Prepare(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj, nil
|
||||||
|
}, atom.GroupRoutes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
53
backend/app/http/tenant_media/routes.gen.go
Normal file
53
backend/app/http/tenant_media/routes.gen.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Code generated by atomctl. DO NOT EDIT.
|
||||||
|
|
||||||
|
// Package tenant_media provides HTTP route definitions and registration
|
||||||
|
// for the quyun/v2 application.
|
||||||
|
package tenant_media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"quyun/v2/app/middlewares"
|
||||||
|
"quyun/v2/database/models"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
_ "go.ipao.vip/atom"
|
||||||
|
_ "go.ipao.vip/atom/contracts"
|
||||||
|
. "go.ipao.vip/atom/fen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Routes implements the HttpRoute contract and provides route registration
|
||||||
|
// for all controllers in the tenant_media module.
|
||||||
|
//
|
||||||
|
// @provider contracts.HttpRoute atom.GroupRoutes
|
||||||
|
type Routes struct {
|
||||||
|
log *log.Entry `inject:"false"`
|
||||||
|
middlewares *middlewares.Middlewares
|
||||||
|
// Controller instances
|
||||||
|
media *media
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare initializes the routes provider with logging configuration.
|
||||||
|
func (r *Routes) Prepare() error {
|
||||||
|
r.log = log.WithField("module", "routes.tenant_media")
|
||||||
|
r.log.Info("Initializing routes module")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the unique identifier for this routes provider.
|
||||||
|
func (r *Routes) Name() string {
|
||||||
|
return "tenant_media"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register registers all HTTP routes with the provided fiber router.
|
||||||
|
// Each route is registered with its corresponding controller action and parameter bindings.
|
||||||
|
func (r *Routes) Register(router fiber.Router) {
|
||||||
|
// Register routes for controller: media
|
||||||
|
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/media/play -> media.play")
|
||||||
|
router.Get("/t/:tenantCode/v1/media/play"[len(r.Path()):], Func2(
|
||||||
|
r.media.play,
|
||||||
|
Local[*models.Tenant]("tenant"),
|
||||||
|
QueryParam[string]("token"),
|
||||||
|
))
|
||||||
|
|
||||||
|
r.log.Info("Successfully registered all routes")
|
||||||
|
}
|
||||||
11
backend/app/http/tenant_media/routes.manual.go
Normal file
11
backend/app/http/tenant_media/routes.manual.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package tenant_media
|
||||||
|
|
||||||
|
func (r *Routes) Path() string {
|
||||||
|
return "/t/:tenantCode/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Routes) Middlewares() []any {
|
||||||
|
return []any{
|
||||||
|
r.middlewares.TenantResolve,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package tenant_public
|
package tenant_public
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
"quyun/v2/app/errorx"
|
"quyun/v2/app/errorx"
|
||||||
tenant_dto "quyun/v2/app/http/tenant/dto"
|
tenant_dto "quyun/v2/app/http/tenant/dto"
|
||||||
"quyun/v2/app/requests"
|
"quyun/v2/app/requests"
|
||||||
@@ -124,6 +128,25 @@ func (*content) previewAssets(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playables := make([]*tenant_dto.ContentPlayableAsset, 0, len(assets))
|
||||||
|
for _, asset := range assets {
|
||||||
|
token, expiresAt, err := services.MediaDelivery.CreatePlayToken(tenant.ID, contentID, asset.ID, consts.ContentAssetRolePreview, uid, 0, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var meta json.RawMessage
|
||||||
|
if len(asset.Meta) > 0 {
|
||||||
|
meta = json.RawMessage(asset.Meta)
|
||||||
|
}
|
||||||
|
playables = append(playables, &tenant_dto.ContentPlayableAsset{
|
||||||
|
AssetID: asset.ID,
|
||||||
|
Type: asset.Type,
|
||||||
|
PlayURL: "/t/" + tenant.Code + "/v1/media/play?token=" + url.QueryEscape(token),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
Meta: meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
previewSeconds := int32(detail.Content.PreviewSeconds)
|
previewSeconds := int32(detail.Content.PreviewSeconds)
|
||||||
if previewSeconds <= 0 {
|
if previewSeconds <= 0 {
|
||||||
previewSeconds = consts.DefaultContentPreviewSeconds
|
previewSeconds = consts.DefaultContentPreviewSeconds
|
||||||
@@ -131,7 +154,7 @@ func (*content) previewAssets(
|
|||||||
|
|
||||||
return &tenant_dto.ContentAssetsResponse{
|
return &tenant_dto.ContentAssetsResponse{
|
||||||
Content: detail.Content,
|
Content: detail.Content,
|
||||||
Assets: assets,
|
Assets: playables,
|
||||||
PreviewSeconds: previewSeconds,
|
PreviewSeconds: previewSeconds,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -174,8 +197,27 @@ func (*content) mainAssets(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playables := make([]*tenant_dto.ContentPlayableAsset, 0, len(assets))
|
||||||
|
for _, asset := range assets {
|
||||||
|
token, expiresAt, err := services.MediaDelivery.CreatePlayToken(tenant.ID, contentID, asset.ID, consts.ContentAssetRoleMain, uid, 0, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var meta json.RawMessage
|
||||||
|
if len(asset.Meta) > 0 {
|
||||||
|
meta = json.RawMessage(asset.Meta)
|
||||||
|
}
|
||||||
|
playables = append(playables, &tenant_dto.ContentPlayableAsset{
|
||||||
|
AssetID: asset.ID,
|
||||||
|
Type: asset.Type,
|
||||||
|
PlayURL: "/t/" + tenant.Code + "/v1/media/play?token=" + url.QueryEscape(token),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
Meta: meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return &tenant_dto.ContentAssetsResponse{
|
return &tenant_dto.ContentAssetsResponse{
|
||||||
Content: detail.Content,
|
Content: detail.Content,
|
||||||
Assets: assets,
|
Assets: playables,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -489,6 +489,8 @@ func (s *content) AssetsByRole(ctx context.Context, tenantID, contentID int64, r
|
|||||||
Select(maTbl.ALL).
|
Select(maTbl.ALL).
|
||||||
Where(
|
Where(
|
||||||
maTbl.TenantID.Eq(tenantID),
|
maTbl.TenantID.Eq(tenantID),
|
||||||
|
maTbl.DeletedAt.IsNull(),
|
||||||
|
maTbl.Status.Eq(consts.MediaAssetStatusReady),
|
||||||
caTbl.TenantID.Eq(tenantID),
|
caTbl.TenantID.Eq(tenantID),
|
||||||
caTbl.ContentID.Eq(contentID),
|
caTbl.ContentID.Eq(contentID),
|
||||||
caTbl.Role.Eq(role),
|
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
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
provider_jwt "quyun/v2/providers/jwt"
|
||||||
|
|
||||||
"go.ipao.vip/atom"
|
"go.ipao.vip/atom"
|
||||||
"go.ipao.vip/atom/container"
|
"go.ipao.vip/atom/container"
|
||||||
"go.ipao.vip/atom/contracts"
|
"go.ipao.vip/atom/contracts"
|
||||||
@@ -34,6 +36,17 @@ func Provide(opts ...opt.Option) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
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(
|
if err := container.Container.Provide(func(
|
||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
ledger *ledger,
|
ledger *ledger,
|
||||||
@@ -52,6 +65,7 @@ func Provide(opts ...opt.Option) error {
|
|||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
ledger *ledger,
|
ledger *ledger,
|
||||||
mediaAsset *mediaAsset,
|
mediaAsset *mediaAsset,
|
||||||
|
mediaDelivery *mediaDelivery,
|
||||||
order *order,
|
order *order,
|
||||||
tenant *tenant,
|
tenant *tenant,
|
||||||
tenantJoin *tenantJoin,
|
tenantJoin *tenantJoin,
|
||||||
@@ -59,15 +73,16 @@ func Provide(opts ...opt.Option) error {
|
|||||||
user *user,
|
user *user,
|
||||||
) (contracts.Initial, error) {
|
) (contracts.Initial, error) {
|
||||||
obj := &services{
|
obj := &services{
|
||||||
content: content,
|
content: content,
|
||||||
db: db,
|
db: db,
|
||||||
ledger: ledger,
|
ledger: ledger,
|
||||||
mediaAsset: mediaAsset,
|
mediaAsset: mediaAsset,
|
||||||
order: order,
|
mediaDelivery: mediaDelivery,
|
||||||
tenant: tenant,
|
order: order,
|
||||||
tenantJoin: tenantJoin,
|
tenant: tenant,
|
||||||
test: test,
|
tenantJoin: tenantJoin,
|
||||||
user: user,
|
test: test,
|
||||||
|
user: user,
|
||||||
}
|
}
|
||||||
if err := obj.Prepare(); err != nil {
|
if err := obj.Prepare(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -8,28 +8,30 @@ var _db *gorm.DB
|
|||||||
|
|
||||||
// exported CamelCase Services
|
// exported CamelCase Services
|
||||||
var (
|
var (
|
||||||
Content *content
|
Content *content
|
||||||
Ledger *ledger
|
Ledger *ledger
|
||||||
MediaAsset *mediaAsset
|
MediaAsset *mediaAsset
|
||||||
Order *order
|
MediaDelivery *mediaDelivery
|
||||||
Tenant *tenant
|
Order *order
|
||||||
TenantJoin *tenantJoin
|
Tenant *tenant
|
||||||
Test *test
|
TenantJoin *tenantJoin
|
||||||
User *user
|
Test *test
|
||||||
|
User *user
|
||||||
)
|
)
|
||||||
|
|
||||||
// @provider(model)
|
// @provider(model)
|
||||||
type services struct {
|
type services struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
// define Services
|
// define Services
|
||||||
content *content
|
content *content
|
||||||
ledger *ledger
|
ledger *ledger
|
||||||
mediaAsset *mediaAsset
|
mediaAsset *mediaAsset
|
||||||
order *order
|
mediaDelivery *mediaDelivery
|
||||||
tenant *tenant
|
order *order
|
||||||
tenantJoin *tenantJoin
|
tenant *tenant
|
||||||
test *test
|
tenantJoin *tenantJoin
|
||||||
user *user
|
test *test
|
||||||
|
user *user
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *services) Prepare() error {
|
func (svc *services) Prepare() error {
|
||||||
@@ -39,6 +41,7 @@ func (svc *services) Prepare() error {
|
|||||||
Content = svc.content
|
Content = svc.content
|
||||||
Ledger = svc.ledger
|
Ledger = svc.ledger
|
||||||
MediaAsset = svc.mediaAsset
|
MediaAsset = svc.mediaAsset
|
||||||
|
MediaDelivery = svc.mediaDelivery
|
||||||
Order = svc.order
|
Order = svc.order
|
||||||
Tenant = svc.tenant
|
Tenant = svc.tenant
|
||||||
TenantJoin = svc.tenantJoin
|
TenantJoin = svc.tenantJoin
|
||||||
|
|||||||
@@ -2340,6 +2340,37 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/t/{tenantCode}/v1/media/play": {
|
||||||
|
"get": {
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"TenantMedia"
|
||||||
|
],
|
||||||
|
"summary": "媒体播放入口(短时效 token)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Tenant Code",
|
||||||
|
"name": "tenantCode",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Play token",
|
||||||
|
"name": "token",
|
||||||
|
"in": "query",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/t/{tenantCode}/v1/orders": {
|
"/t/{tenantCode}/v1/orders": {
|
||||||
"get": {
|
"get": {
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -3235,10 +3266,10 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"assets": {
|
"assets": {
|
||||||
"description": "Assets is the list of media assets for the requested role (preview/main).",
|
"description": "Assets is the list of playable assets for the requested role (preview/main).",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/models.MediaAsset"
|
"$ref": "#/definitions/dto.ContentPlayableAsset"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"content": {
|
"content": {
|
||||||
@@ -3330,6 +3361,32 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dto.ContentPlayableAsset": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"asset_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"description": "ExpiresAt indicates when PlayURL/token expires; optional.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"description": "Meta is a display-safe whitelist (currently passthrough JSON); optional.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"play_url": {
|
||||||
|
"description": "PlayURL is a short-lived URL; do NOT expose bucket/object_key directly.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"$ref": "#/definitions/consts.MediaAssetType"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"dto.ContentPriceUpsertForm": {
|
"dto.ContentPriceUpsertForm": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -2334,6 +2334,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/t/{tenantCode}/v1/media/play": {
|
||||||
|
"get": {
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"TenantMedia"
|
||||||
|
],
|
||||||
|
"summary": "媒体播放入口(短时效 token)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Tenant Code",
|
||||||
|
"name": "tenantCode",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Play token",
|
||||||
|
"name": "token",
|
||||||
|
"in": "query",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/t/{tenantCode}/v1/orders": {
|
"/t/{tenantCode}/v1/orders": {
|
||||||
"get": {
|
"get": {
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -3229,10 +3260,10 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"assets": {
|
"assets": {
|
||||||
"description": "Assets is the list of media assets for the requested role (preview/main).",
|
"description": "Assets is the list of playable assets for the requested role (preview/main).",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/models.MediaAsset"
|
"$ref": "#/definitions/dto.ContentPlayableAsset"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"content": {
|
"content": {
|
||||||
@@ -3324,6 +3355,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dto.ContentPlayableAsset": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"asset_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"description": "ExpiresAt indicates when PlayURL/token expires; optional.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"description": "Meta is a display-safe whitelist (currently passthrough JSON); optional.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"play_url": {
|
||||||
|
"description": "PlayURL is a short-lived URL; do NOT expose bucket/object_key directly.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"$ref": "#/definitions/consts.MediaAssetType"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"dto.ContentPriceUpsertForm": {
|
"dto.ContentPriceUpsertForm": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -449,9 +449,10 @@ definitions:
|
|||||||
dto.ContentAssetsResponse:
|
dto.ContentAssetsResponse:
|
||||||
properties:
|
properties:
|
||||||
assets:
|
assets:
|
||||||
description: Assets is the list of media assets for the requested role (preview/main).
|
description: Assets is the list of playable assets for the requested role
|
||||||
|
(preview/main).
|
||||||
items:
|
items:
|
||||||
$ref: '#/definitions/models.MediaAsset'
|
$ref: '#/definitions/dto.ContentPlayableAsset'
|
||||||
type: array
|
type: array
|
||||||
content:
|
content:
|
||||||
allOf:
|
allOf:
|
||||||
@@ -512,6 +513,26 @@ definitions:
|
|||||||
description: Price is the current price settings for the content (may be nil
|
description: Price is the current price settings for the content (may be nil
|
||||||
if not set).
|
if not set).
|
||||||
type: object
|
type: object
|
||||||
|
dto.ContentPlayableAsset:
|
||||||
|
properties:
|
||||||
|
asset_id:
|
||||||
|
type: integer
|
||||||
|
expires_at:
|
||||||
|
description: ExpiresAt indicates when PlayURL/token expires; optional.
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
description: Meta is a display-safe whitelist (currently passthrough JSON);
|
||||||
|
optional.
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
type: array
|
||||||
|
play_url:
|
||||||
|
description: PlayURL is a short-lived URL; do NOT expose bucket/object_key
|
||||||
|
directly.
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
$ref: '#/definitions/consts.MediaAssetType'
|
||||||
|
type: object
|
||||||
dto.ContentPriceUpsertForm:
|
dto.ContentPriceUpsertForm:
|
||||||
properties:
|
properties:
|
||||||
currency:
|
currency:
|
||||||
@@ -2855,6 +2876,27 @@ paths:
|
|||||||
summary: 当前租户余额流水(分页)
|
summary: 当前租户余额流水(分页)
|
||||||
tags:
|
tags:
|
||||||
- Tenant
|
- Tenant
|
||||||
|
/t/{tenantCode}/v1/media/play:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- description: Tenant Code
|
||||||
|
in: path
|
||||||
|
name: tenantCode
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Play token
|
||||||
|
in: query
|
||||||
|
name: token
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses: {}
|
||||||
|
summary: 媒体播放入口(短时效 token)
|
||||||
|
tags:
|
||||||
|
- TenantMedia
|
||||||
/t/{tenantCode}/v1/orders:
|
/t/{tenantCode}/v1/orders:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ GET {{ host }}/t/{{ tenantCode }}/v1/contents/{{ contentID }}/preview
|
|||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer {{ token }}
|
Authorization: Bearer {{ token }}
|
||||||
|
|
||||||
|
### Tenant - Media play (token-based; no auth header needed)
|
||||||
|
# Copy a `play_url` from preview/assets response and paste it here (or set @playToken).
|
||||||
|
@playToken = paste_play_token_here
|
||||||
|
GET {{ host }}/t/{{ tenantCode }}/v1/media/play?token={{ playToken }}
|
||||||
|
|
||||||
### Tenant - Main assets (role=main, requires access unless free/owner)
|
### Tenant - Main assets (role=main, requires access unless free/owner)
|
||||||
GET {{ host }}/t/{{ tenantCode }}/v1/contents/{{ contentID }}/assets
|
GET {{ host }}/t/{{ tenantCode }}/v1/contents/{{ contentID }}/assets
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ Content-Type: application/json
|
|||||||
GET {{ host }}/t/{{ tenantCode }}/v1/public/contents/{{ contentID }}/preview
|
GET {{ host }}/t/{{ tenantCode }}/v1/public/contents/{{ contentID }}/preview
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
|
### Tenant Public - Media play (token-based; no auth header needed)
|
||||||
|
# Copy a `play_url` from preview/assets response and paste it here (or set @playToken).
|
||||||
|
@playToken = paste_play_token_here
|
||||||
|
GET {{ host }}/t/{{ tenantCode }}/v1/media/play?token={{ playToken }}
|
||||||
|
|
||||||
### Tenant Public - Main assets (role=main), guest
|
### Tenant Public - Main assets (role=main), guest
|
||||||
# Rule confirmed: public + price=0 => guest can access main assets.
|
# Rule confirmed: public + price=0 => guest can access main assets.
|
||||||
GET {{ host }}/t/{{ tenantCode }}/v1/public/contents/{{ contentID }}/assets
|
GET {{ host }}/t/{{ tenantCode }}/v1/public/contents/{{ contentID }}/assets
|
||||||
@@ -33,4 +38,3 @@ Content-Type: application/json
|
|||||||
GET {{ host }}/t/{{ tenantCode }}/v1/public/contents/{{ contentID }}/assets
|
GET {{ host }}/t/{{ tenantCode }}/v1/public/contents/{{ contentID }}/assets
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer {{ token }}
|
Authorization: Bearer {{ token }}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user