From 335a546aab60b1cf76146a2ed51665dae9e4fe23 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 22 Dec 2025 17:44:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=9F=BA=E4=BA=8E=E7=9F=AD=E6=97=B6=E6=95=88token=E7=9A=84?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=85=A5=E5=8F=A3=E5=8F=8A=E7=9B=B8=E5=85=B3?= =?UTF-8?q?API=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/commands/http/http.go | 2 + backend/app/http/tenant/content.go | 46 ++++- backend/app/http/tenant/dto/content.go | 4 +- .../app/http/tenant/dto/content_asset_play.go | 23 +++ backend/app/http/tenant_media/play.go | 38 ++++ backend/app/http/tenant_media/provider.gen.go | 37 ++++ backend/app/http/tenant_media/routes.gen.go | 53 ++++++ .../app/http/tenant_media/routes.manual.go | 11 ++ backend/app/http/tenant_public/content.go | 46 ++++- backend/app/services/content.go | 2 + backend/app/services/media_delivery.go | 168 ++++++++++++++++++ backend/app/services/provider.gen.go | 33 +++- backend/app/services/services.gen.go | 35 ++-- backend/docs/docs.go | 61 ++++++- backend/docs/swagger.json | 61 ++++++- backend/docs/swagger.yaml | 46 ++++- backend/tests/tenant.http | 5 + backend/tests/tenant_public.http | 6 +- 18 files changed, 639 insertions(+), 38 deletions(-) create mode 100644 backend/app/http/tenant/dto/content_asset_play.go create mode 100644 backend/app/http/tenant_media/play.go create mode 100755 backend/app/http/tenant_media/provider.gen.go create mode 100644 backend/app/http/tenant_media/routes.gen.go create mode 100644 backend/app/http/tenant_media/routes.manual.go create mode 100644 backend/app/services/media_delivery.go diff --git a/backend/app/commands/http/http.go b/backend/app/commands/http/http.go index ace0555..f1c6508 100644 --- a/backend/app/commands/http/http.go +++ b/backend/app/commands/http/http.go @@ -9,6 +9,7 @@ import ( "quyun/v2/app/http/super" "quyun/v2/app/http/tenant" "quyun/v2/app/http/tenant_join" + "quyun/v2/app/http/tenant_media" "quyun/v2/app/http/tenant_public" "quyun/v2/app/jobs" "quyun/v2/app/middlewares" @@ -57,6 +58,7 @@ func Command() atom.Option { tenant.Provide, tenant_join.Provide, tenant_public.Provide, + tenant_media.Provide, // {Provider: api.Provide}, // {Provider: web.Provide}, ), diff --git a/backend/app/http/tenant/content.go b/backend/app/http/tenant/content.go index 7bc949b..26ee026 100644 --- a/backend/app/http/tenant/content.go +++ b/backend/app/http/tenant/content.go @@ -1,6 +1,10 @@ package tenant import ( + "encoding/json" + "net/url" + "time" + "quyun/v2/app/errorx" "quyun/v2/app/http/tenant/dto" "quyun/v2/app/requests" @@ -104,13 +108,32 @@ func (*content) previewAssets(ctx fiber.Ctx, tenant *models.Tenant, user *models 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) if previewSeconds <= 0 { previewSeconds = consts.DefaultContentPreviewSeconds } return &dto.ContentAssetsResponse{ Content: detail.Content, - Assets: assets, + Assets: playables, PreviewSeconds: previewSeconds, }, nil } @@ -150,8 +173,27 @@ func (*content) mainAssets(ctx fiber.Ctx, tenant *models.Tenant, user *models.Us 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{ Content: detail.Content, - Assets: assets, + Assets: playables, }, nil } diff --git a/backend/app/http/tenant/dto/content.go b/backend/app/http/tenant/dto/content.go index 1fb184d..a55261d 100644 --- a/backend/app/http/tenant/dto/content.go +++ b/backend/app/http/tenant/dto/content.go @@ -37,8 +37,8 @@ type ContentDetail struct { type ContentAssetsResponse struct { // Content is the content entity. Content *models.Content `json:"content,omitempty"` - // Assets is the list of media assets for the requested role (preview/main). - Assets []*models.MediaAsset `json:"assets,omitempty"` + // Assets is the list of playable assets for the requested role (preview/main). + Assets []*ContentPlayableAsset `json:"assets,omitempty"` // PreviewSeconds indicates the max preview duration (only meaningful for preview response). PreviewSeconds int32 `json:"preview_seconds,omitempty"` } diff --git a/backend/app/http/tenant/dto/content_asset_play.go b/backend/app/http/tenant/dto/content_asset_play.go new file mode 100644 index 0000000..9001f6e --- /dev/null +++ b/backend/app/http/tenant/dto/content_asset_play.go @@ -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"` +} diff --git a/backend/app/http/tenant_media/play.go b/backend/app/http/tenant_media/play.go new file mode 100644 index 0000000..8754e62 --- /dev/null +++ b/backend/app/http/tenant_media/play.go @@ -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) +} diff --git a/backend/app/http/tenant_media/provider.gen.go b/backend/app/http/tenant_media/provider.gen.go new file mode 100755 index 0000000..a008fe6 --- /dev/null +++ b/backend/app/http/tenant_media/provider.gen.go @@ -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 +} diff --git a/backend/app/http/tenant_media/routes.gen.go b/backend/app/http/tenant_media/routes.gen.go new file mode 100644 index 0000000..5a1858c --- /dev/null +++ b/backend/app/http/tenant_media/routes.gen.go @@ -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") +} diff --git a/backend/app/http/tenant_media/routes.manual.go b/backend/app/http/tenant_media/routes.manual.go new file mode 100644 index 0000000..15effc4 --- /dev/null +++ b/backend/app/http/tenant_media/routes.manual.go @@ -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, + } +} diff --git a/backend/app/http/tenant_public/content.go b/backend/app/http/tenant_public/content.go index ea94342..6a4ecff 100644 --- a/backend/app/http/tenant_public/content.go +++ b/backend/app/http/tenant_public/content.go @@ -1,6 +1,10 @@ package tenant_public import ( + "encoding/json" + "net/url" + "time" + "quyun/v2/app/errorx" tenant_dto "quyun/v2/app/http/tenant/dto" "quyun/v2/app/requests" @@ -124,6 +128,25 @@ func (*content) previewAssets( 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) if previewSeconds <= 0 { previewSeconds = consts.DefaultContentPreviewSeconds @@ -131,7 +154,7 @@ func (*content) previewAssets( return &tenant_dto.ContentAssetsResponse{ Content: detail.Content, - Assets: assets, + Assets: playables, PreviewSeconds: previewSeconds, }, nil } @@ -174,8 +197,27 @@ func (*content) mainAssets( 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{ Content: detail.Content, - Assets: assets, + Assets: playables, }, nil } diff --git a/backend/app/services/content.go b/backend/app/services/content.go index e8f9e0a..5362c59 100644 --- a/backend/app/services/content.go +++ b/backend/app/services/content.go @@ -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), diff --git a/backend/app/services/media_delivery.go b/backend/app/services/media_delivery.go new file mode 100644 index 0000000..9283f55 --- /dev/null +++ b/backend/app/services/media_delivery.go @@ -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) + } +} diff --git a/backend/app/services/provider.gen.go b/backend/app/services/provider.gen.go index 42f9eb8..c018fe8 100755 --- a/backend/app/services/provider.gen.go +++ b/backend/app/services/provider.gen.go @@ -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 diff --git a/backend/app/services/services.gen.go b/backend/app/services/services.gen.go index bbeff1c..0ceacc9 100644 --- a/backend/app/services/services.gen.go +++ b/backend/app/services/services.gen.go @@ -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 diff --git a/backend/docs/docs.go b/backend/docs/docs.go index b0644ed..153a0a7 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -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": { "get": { "consumes": [ @@ -3235,10 +3266,10 @@ const docTemplate = `{ "type": "object", "properties": { "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", "items": { - "$ref": "#/definitions/models.MediaAsset" + "$ref": "#/definitions/dto.ContentPlayableAsset" } }, "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": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 18efe2d..c23a7c5 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -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": { "get": { "consumes": [ @@ -3229,10 +3260,10 @@ "type": "object", "properties": { "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", "items": { - "$ref": "#/definitions/models.MediaAsset" + "$ref": "#/definitions/dto.ContentPlayableAsset" } }, "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": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 2259ffe..ad36c6d 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -449,9 +449,10 @@ definitions: dto.ContentAssetsResponse: properties: 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: - $ref: '#/definitions/models.MediaAsset' + $ref: '#/definitions/dto.ContentPlayableAsset' type: array content: allOf: @@ -512,6 +513,26 @@ definitions: description: Price is the current price settings for the content (may be nil if not set). 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: properties: currency: @@ -2855,6 +2876,27 @@ paths: summary: 当前租户余额流水(分页) tags: - 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: get: consumes: diff --git a/backend/tests/tenant.http b/backend/tests/tenant.http index 9ae8162..14ce6c1 100644 --- a/backend/tests/tenant.http +++ b/backend/tests/tenant.http @@ -56,6 +56,11 @@ GET {{ host }}/t/{{ tenantCode }}/v1/contents/{{ contentID }}/preview Content-Type: application/json 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) GET {{ host }}/t/{{ tenantCode }}/v1/contents/{{ contentID }}/assets Content-Type: application/json diff --git a/backend/tests/tenant_public.http b/backend/tests/tenant_public.http index 8423b31..f502b3f 100644 --- a/backend/tests/tenant_public.http +++ b/backend/tests/tenant_public.http @@ -24,6 +24,11 @@ Content-Type: application/json GET {{ host }}/t/{{ tenantCode }}/v1/public/contents/{{ contentID }}/preview 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 # Rule confirmed: public + price=0 => guest can access main 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 Content-Type: application/json Authorization: Bearer {{ token }} -