diff --git a/backend/app/http/tenant/dto/media_asset_admin.go b/backend/app/http/tenant/dto/media_asset_admin.go index d2609ae..ae79938 100644 --- a/backend/app/http/tenant/dto/media_asset_admin.go +++ b/backend/app/http/tenant/dto/media_asset_admin.go @@ -1,6 +1,10 @@ package dto -import "time" +import ( + "time" + + "quyun/v2/pkg/consts" +) // AdminMediaAssetUploadInitForm defines payload for tenant-admin to initialize a media asset upload. type AdminMediaAssetUploadInitForm struct { @@ -10,7 +14,7 @@ type AdminMediaAssetUploadInitForm struct { // Variant indicates whether this asset is a main or preview product. // Allowed: main/preview; default is main. - Variant string `json:"variant,omitempty"` + Variant *consts.MediaAssetVariant `json:"variant,omitempty"` // SourceAssetID links a preview product to its main asset; only meaningful when variant=preview. SourceAssetID *int64 `json:"source_asset_id,omitempty"` diff --git a/backend/app/services/content.go b/backend/app/services/content.go index 9f745b7..3f6097d 100644 --- a/backend/app/services/content.go +++ b/backend/app/services/content.go @@ -34,13 +34,13 @@ type ContentDetailResult struct { HasAccess bool } -func requiredMediaAssetVariantForRole(role consts.ContentAssetRole) string { +func requiredMediaAssetVariantForRole(role consts.ContentAssetRole) consts.MediaAssetVariant { switch role { case consts.ContentAssetRolePreview: - return mediaAssetVariantPreview + return consts.MediaAssetVariantPreview default: // main/cover 一律要求 main 产物,避免误把 preview 绑定成正片/封面。 - return mediaAssetVariantMain + return consts.MediaAssetVariantMain } } @@ -226,23 +226,9 @@ func (s *content) AttachAsset(ctx context.Context, tenantID, userID, contentID, } // C2 规则:preview 必须绑定独立产物(media_assets.variant=preview),main/cover 必须为 main。 - var assetRow struct { - Variant string `gorm:"column:variant"` - SourceAssetID *int64 `gorm:"column:source_asset_id"` - } - if err := _db.WithContext(ctx). - Table(models.TableNameMediaAsset). - Select("variant, source_asset_id"). - Where("tenant_id = ? AND id = ? AND deleted_at IS NULL", tenantID, assetID). - Take(&assetRow).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errorx.ErrRecordNotFound.WithMsg("media asset not found") - } - return nil, err - } - variant := assetRow.Variant + variant := asset.Variant if variant == "" { - variant = mediaAssetVariantMain + variant = consts.MediaAssetVariantMain } requiredVariant := requiredMediaAssetVariantForRole(role) if variant != requiredVariant { @@ -250,31 +236,29 @@ func (s *content) AttachAsset(ctx context.Context, tenantID, userID, contentID, } // 关联规则:preview 产物必须声明来源 main;main/cover 不允许带来源。 if role == consts.ContentAssetRolePreview { - if assetRow.SourceAssetID == nil || *assetRow.SourceAssetID <= 0 { + if asset.SourceAssetID <= 0 { return nil, errorx.ErrPreconditionFailed.WithMsg("preview asset must have source_asset_id") } - var srcRow struct { - Variant string `gorm:"column:variant"` - } - if err := _db.WithContext(ctx). - Table(models.TableNameMediaAsset). - Select("variant"). - Where("tenant_id = ? AND id = ? AND deleted_at IS NULL", tenantID, *assetRow.SourceAssetID). - Take(&srcRow).Error; err != nil { + src, err := queryAsset.Where( + tblAsset.TenantID.Eq(tenantID), + tblAsset.ID.Eq(asset.SourceAssetID), + tblAsset.DeletedAt.IsNull(), + ).First() + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.ErrRecordNotFound.WithMsg("preview source asset not found") } return nil, err } - srcVariant := srcRow.Variant + srcVariant := src.Variant if srcVariant == "" { - srcVariant = mediaAssetVariantMain + srcVariant = consts.MediaAssetVariantMain } - if srcVariant != mediaAssetVariantMain { + if srcVariant != consts.MediaAssetVariantMain { return nil, errorx.ErrPreconditionFailed.WithMsg("preview source asset must be main variant") } } else { - if assetRow.SourceAssetID != nil && *assetRow.SourceAssetID > 0 { + if asset.SourceAssetID > 0 { return nil, errorx.ErrPreconditionFailed.WithMsg("main/cover asset must not have source_asset_id") } } diff --git a/backend/app/services/media_asset.go b/backend/app/services/media_asset.go index 934a4d6..1433d0e 100644 --- a/backend/app/services/media_asset.go +++ b/backend/app/services/media_asset.go @@ -31,11 +31,6 @@ import ( // @provider type mediaAsset struct{} -const ( - mediaAssetVariantMain = "main" - mediaAssetVariantPreview = "preview" -) - func mediaAssetTransitionAllowed(from, to consts.MediaAssetStatus) bool { switch from { case consts.MediaAssetStatusUploaded: @@ -82,11 +77,11 @@ func (s *mediaAsset) AdminUploadInit(ctx context.Context, tenantID, operatorUser return nil, errorx.ErrInvalidParameter.WithMsg("invalid type") } - variant := strings.TrimSpace(strings.ToLower(form.Variant)) - if variant == "" { - variant = mediaAssetVariantMain + variant := consts.MediaAssetVariantMain + if form.Variant != nil { + variant = *form.Variant } - if variant != mediaAssetVariantMain && variant != mediaAssetVariantPreview { + if variant == "" || !variant.IsValid() { return nil, errorx.ErrInvalidParameter.WithMsg("invalid variant") } @@ -94,7 +89,7 @@ func (s *mediaAsset) AdminUploadInit(ctx context.Context, tenantID, operatorUser if form.SourceAssetID != nil { sourceAssetID = *form.SourceAssetID } - if variant == mediaAssetVariantMain { + if variant == consts.MediaAssetVariantMain { if sourceAssetID != 0 { return nil, errorx.ErrInvalidParameter.WithMsg("source_asset_id is only allowed for preview variant") } @@ -104,24 +99,23 @@ func (s *mediaAsset) AdminUploadInit(ctx context.Context, tenantID, operatorUser return nil, errorx.ErrInvalidParameter.WithMsg("source_asset_id is required for preview variant") } // 校验来源资源存在、同租户、未删除、且为 main 产物。 - var srcRow struct { - Variant string `gorm:"column:variant"` - } - if err := _db.WithContext(ctx). - Table(models.TableNameMediaAsset). - Select("variant"). - Where("tenant_id = ? AND id = ? AND deleted_at IS NULL", tenantID, sourceAssetID). - Take(&srcRow).Error; err != nil { + tbl, query := models.MediaAssetQuery.QueryContext(ctx) + src, err := query.Where( + tbl.TenantID.Eq(tenantID), + tbl.ID.Eq(sourceAssetID), + tbl.DeletedAt.IsNull(), + ).First() + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.ErrRecordNotFound.WithMsg("source media asset not found") } return nil, err } - srcVariant := srcRow.Variant + srcVariant := src.Variant if srcVariant == "" { - srcVariant = mediaAssetVariantMain + srcVariant = consts.MediaAssetVariantMain } - if srcVariant != mediaAssetVariantMain { + if srcVariant != consts.MediaAssetVariantMain { return nil, errorx.ErrPreconditionFailed.WithMsg("source asset must be main variant") } } @@ -163,16 +157,18 @@ func (s *mediaAsset) AdminUploadInit(ctx context.Context, tenantID, operatorUser } // variant/source_asset_id 目前为 DB 新增字段;由于 models 为 gen 产物,这里用 SQL 更新列值。 - updates := map[string]any{ - "variant": variant, + tbl, query := models.MediaAssetQuery.QueryContext(ctx) + // variant/source_asset_id 已生成模型字段,使用 UpdateSimple 保持类型安全。 + assigns := []field.AssignExpr{ + tbl.Variant.Value(variant), } if sourceAssetID > 0 { - updates["source_asset_id"] = sourceAssetID + assigns = append(assigns, tbl.SourceAssetID.Value(sourceAssetID)) } - if err := _db.WithContext(ctx). - Model(&models.MediaAsset{}). - Where("id = ? AND tenant_id = ?", m.ID, tenantID). - Updates(updates).Error; err != nil { + if _, err := query.Where( + tbl.ID.Eq(m.ID), + tbl.TenantID.Eq(tenantID), + ).UpdateSimple(assigns...); err != nil { return nil, pkgerrors.Wrap(err, "update media asset variant/source_asset_id failed") } @@ -224,12 +220,16 @@ func (s *mediaAsset) AdminUploadComplete( var out models.MediaAsset - err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var m models.MediaAsset - if err := tx. + err := models.Q.Transaction(func(tx *models.Query) error { + tbl, query := tx.MediaAsset.QueryContext(ctx) + m, err := query. Clauses(clause.Locking{Strength: "UPDATE"}). - Where("tenant_id = ? AND id = ?", tenantID, assetID). - First(&m).Error; err != nil { + Where( + tbl.TenantID.Eq(tenantID), + tbl.ID.Eq(assetID), + ). + First() + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errorx.ErrRecordNotFound.WithMsg("media asset not found") } @@ -244,7 +244,7 @@ func (s *mediaAsset) AdminUploadComplete( // 幂等:重复 upload_complete 时返回现态。 switch m.Status { case consts.MediaAssetStatusProcessing, consts.MediaAssetStatusReady, consts.MediaAssetStatusFailed: - out = m + out = *m return nil case consts.MediaAssetStatusUploaded: // allowed @@ -281,20 +281,18 @@ func (s *mediaAsset) AdminUploadComplete( if !mediaAssetTransitionAllowed(m.Status, consts.MediaAssetStatusProcessing) { return errorx.ErrStatusConflict.WithMsg("invalid media asset status transition") } - if err := tx.Model(&models.MediaAsset{}). - Where("id = ?", m.ID). - Updates(map[string]any{ - "status": consts.MediaAssetStatusProcessing, - "meta": types.JSON(metaBytes), - "updated_at": now, - }).Error; err != nil { + if _, err := query.Where(tbl.ID.Eq(m.ID)).Updates(map[string]any{ + "status": consts.MediaAssetStatusProcessing, + "meta": types.JSON(metaBytes), + "updated_at": now, + }); err != nil { return err } m.Status = consts.MediaAssetStatusProcessing m.Meta = types.JSON(metaBytes) m.UpdatedAt = now - out = m + out = *m // 触发异步处理(当前为 stub):后续接入队列/任务系统时在此处落任务并保持幂等。 logrus.WithFields(logrus.Fields{ @@ -328,12 +326,16 @@ func (s *mediaAsset) ProcessSuccess( } var out models.MediaAsset - err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var m models.MediaAsset - if err := tx. + err := models.Q.Transaction(func(tx *models.Query) error { + tbl, query := tx.MediaAsset.QueryContext(ctx) + m, err := query. Clauses(clause.Locking{Strength: "UPDATE"}). - Where("tenant_id = ? AND id = ?", tenantID, assetID). - First(&m).Error; err != nil { + Where( + tbl.TenantID.Eq(tenantID), + tbl.ID.Eq(assetID), + ). + First() + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errorx.ErrRecordNotFound.WithMsg("media asset not found") } @@ -343,7 +345,7 @@ func (s *mediaAsset) ProcessSuccess( return errorx.ErrPreconditionFailed.WithMsg("media asset deleted") } if m.Status == consts.MediaAssetStatusReady { - out = m + out = *m return nil } if !mediaAssetTransitionAllowed(m.Status, consts.MediaAssetStatusReady) { @@ -366,19 +368,17 @@ func (s *mediaAsset) ProcessSuccess( metaBytes = []byte("{}") } - if err := tx.Model(&models.MediaAsset{}). - Where("id = ?", m.ID). - Updates(map[string]any{ - "status": consts.MediaAssetStatusReady, - "meta": types.JSON(metaBytes), - "updated_at": now, - }).Error; err != nil { + if _, err := query.Where(tbl.ID.Eq(m.ID)).Updates(map[string]any{ + "status": consts.MediaAssetStatusReady, + "meta": types.JSON(metaBytes), + "updated_at": now, + }); err != nil { return err } m.Status = consts.MediaAssetStatusReady m.Meta = types.JSON(metaBytes) m.UpdatedAt = now - out = m + out = *m return nil }) if err != nil { @@ -403,12 +403,16 @@ func (s *mediaAsset) ProcessFailed( } var out models.MediaAsset - err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var m models.MediaAsset - if err := tx. + err := models.Q.Transaction(func(tx *models.Query) error { + tbl, query := tx.MediaAsset.QueryContext(ctx) + m, err := query. Clauses(clause.Locking{Strength: "UPDATE"}). - Where("tenant_id = ? AND id = ?", tenantID, assetID). - First(&m).Error; err != nil { + Where( + tbl.TenantID.Eq(tenantID), + tbl.ID.Eq(assetID), + ). + First() + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errorx.ErrRecordNotFound.WithMsg("media asset not found") } @@ -418,7 +422,7 @@ func (s *mediaAsset) ProcessFailed( return errorx.ErrPreconditionFailed.WithMsg("media asset deleted") } if m.Status == consts.MediaAssetStatusFailed { - out = m + out = *m return nil } if !mediaAssetTransitionAllowed(m.Status, consts.MediaAssetStatusFailed) { @@ -438,19 +442,17 @@ func (s *mediaAsset) ProcessFailed( metaBytes = []byte("{}") } - if err := tx.Model(&models.MediaAsset{}). - Where("id = ?", m.ID). - Updates(map[string]any{ - "status": consts.MediaAssetStatusFailed, - "meta": types.JSON(metaBytes), - "updated_at": now, - }).Error; err != nil { + if _, err := query.Where(tbl.ID.Eq(m.ID)).Updates(map[string]any{ + "status": consts.MediaAssetStatusFailed, + "meta": types.JSON(metaBytes), + "updated_at": now, + }); err != nil { return err } m.Status = consts.MediaAssetStatusFailed m.Meta = types.JSON(metaBytes) m.UpdatedAt = now - out = m + out = *m return nil }) if err != nil { @@ -475,12 +477,16 @@ func (s *mediaAsset) AdminDelete(ctx context.Context, tenantID, operatorUserID, }).Info("services.media_asset.admin.delete") var out models.MediaAsset - err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var m models.MediaAsset - if err := tx. + err := models.Q.Transaction(func(tx *models.Query) error { + tbl, query := tx.MediaAsset.QueryContext(ctx) + m, err := query. Clauses(clause.Locking{Strength: "UPDATE"}). - Where("tenant_id = ? AND id = ?", tenantID, assetID). - First(&m).Error; err != nil { + Where( + tbl.TenantID.Eq(tenantID), + tbl.ID.Eq(assetID), + ). + First() + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errorx.ErrRecordNotFound.WithMsg("media asset not found") } @@ -489,7 +495,7 @@ func (s *mediaAsset) AdminDelete(ctx context.Context, tenantID, operatorUserID, // 幂等:已删除直接返回。 if m.DeletedAt.Valid || m.Status == consts.MediaAssetStatusDeleted { - out = m + out = *m return nil } @@ -497,22 +503,20 @@ func (s *mediaAsset) AdminDelete(ctx context.Context, tenantID, operatorUserID, return errorx.ErrStatusConflict.WithMsg("invalid media asset status transition") } - if err := tx.Model(&models.MediaAsset{}). - Where("id = ?", m.ID). - Updates(map[string]any{ - "status": consts.MediaAssetStatusDeleted, - "updated_at": now, - }).Error; err != nil { + if _, err := query.Where(tbl.ID.Eq(m.ID)).Updates(map[string]any{ + "status": consts.MediaAssetStatusDeleted, + "updated_at": now, + }); err != nil { return err } - if err := tx.Delete(&m).Error; err != nil { + if _, err := query.Where(tbl.ID.Eq(m.ID)).Delete(); err != nil { return err } m.Status = consts.MediaAssetStatusDeleted m.UpdatedAt = now - out = m + out = *m return nil }) if err != nil { diff --git a/backend/app/services/media_asset_test.go b/backend/app/services/media_asset_test.go index 8d6afea..94511f8 100644 --- a/backend/app/services/media_asset_test.go +++ b/backend/app/services/media_asset_test.go @@ -53,9 +53,10 @@ func (s *MediaAssetTestSuite) Test_AdminUploadInit_VariantAndSource() { Convey("main variant 不允许 source_asset_id", func() { src := int64(123) + v := consts.MediaAssetVariantMain _, err := MediaAsset.AdminUploadInit(ctx, tenantID, userID, &tenant_dto.AdminMediaAssetUploadInitForm{ Type: "video", - Variant: "main", + Variant: &v, SourceAssetID: &src, }, now) So(err, ShouldNotBeNil) @@ -65,9 +66,10 @@ func (s *MediaAssetTestSuite) Test_AdminUploadInit_VariantAndSource() { }) Convey("preview variant 必须带 source_asset_id", func() { + v := consts.MediaAssetVariantPreview _, err := MediaAsset.AdminUploadInit(ctx, tenantID, userID, &tenant_dto.AdminMediaAssetUploadInitForm{ Type: "video", - Variant: "preview", + Variant: &v, }, now) So(err, ShouldNotBeNil) var appErr *errorx.AppError @@ -94,9 +96,10 @@ func (s *MediaAssetTestSuite) Test_AdminUploadInit_VariantAndSource() { _, err := s.DB.ExecContext(ctx, "UPDATE media_assets SET variant = 'preview' WHERE tenant_id = $1 AND id = $2", tenantID, src.ID) So(err, ShouldBeNil) + v := consts.MediaAssetVariantPreview _, err = MediaAsset.AdminUploadInit(ctx, tenantID, userID, &tenant_dto.AdminMediaAssetUploadInitForm{ Type: "video", - Variant: "preview", + Variant: &v, SourceAssetID: &src.ID, }, now) So(err, ShouldNotBeNil) @@ -106,4 +109,3 @@ func (s *MediaAssetTestSuite) Test_AdminUploadInit_VariantAndSource() { }) }) } - diff --git a/backend/app/services/media_delivery.go b/backend/app/services/media_delivery.go index 9283f55..9c963d5 100644 --- a/backend/app/services/media_delivery.go +++ b/backend/app/services/media_delivery.go @@ -12,7 +12,6 @@ import ( jwtlib "github.com/golang-jwt/jwt/v4" log "github.com/sirupsen/logrus" - "go.ipao.vip/gen/types" "gorm.io/gorm" ) @@ -126,10 +125,13 @@ func (s *mediaDelivery) ResolvePlayRedirect(ctx context.Context, tenantID int64, "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 { + 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") } @@ -140,16 +142,13 @@ func (s *mediaDelivery) ResolvePlayRedirect(ctx context.Context, tenantID int64, } // 二次校验: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 { + 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") } @@ -162,7 +161,6 @@ func (s *mediaDelivery) ResolvePlayRedirect(ctx context.Context, tenantID int64, 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/database/.transform.yaml b/backend/database/.transform.yaml index a69c750..5c1db56 100644 --- a/backend/database/.transform.yaml +++ b/backend/database/.transform.yaml @@ -22,6 +22,7 @@ field_type: media_assets: type: consts.MediaAssetType status: consts.MediaAssetStatus + variant: consts.MediaAssetVariant contents: status: consts.ContentStatus visibility: consts.ContentVisibility diff --git a/backend/database/migrations/20251222174000_media_assets_variant.sql b/backend/database/migrations/20251222174000_media_assets_variant.sql index 1aa2b3f..310640f 100644 --- a/backend/database/migrations/20251222174000_media_assets_variant.sql +++ b/backend/database/migrations/20251222174000_media_assets_variant.sql @@ -9,9 +9,19 @@ SET variant = 'main' WHERE variant IS NULL OR variant = ''; -- 约束:只允许 main/preview -ALTER TABLE media_assets - ADD CONSTRAINT IF NOT EXISTS ck_media_assets_variant - CHECK (variant IN ('main', 'preview')); +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'ck_media_assets_variant' + ) THEN + ALTER TABLE media_assets + ADD CONSTRAINT ck_media_assets_variant + CHECK (variant IN ('main', 'preview')); + END IF; +END +$$; COMMENT ON COLUMN media_assets.variant IS '产物类型:main/preview;用于强制试看资源必须绑定独立产物,避免用正片绕过'; @@ -24,4 +34,3 @@ DROP INDEX IF EXISTS ix_media_assets_tenant_variant; ALTER TABLE media_assets DROP CONSTRAINT IF EXISTS ck_media_assets_variant; ALTER TABLE media_assets DROP COLUMN IF EXISTS variant; -- +goose StatementEnd - diff --git a/backend/database/models/media_assets.gen.go b/backend/database/models/media_assets.gen.go index 497c892..05e633a 100644 --- a/backend/database/models/media_assets.gen.go +++ b/backend/database/models/media_assets.gen.go @@ -19,18 +19,20 @@ const TableNameMediaAsset = "media_assets" // MediaAsset mapped from table type MediaAsset struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增;仅用于内部关联" json:"id"` // 主键ID:自增;仅用于内部关联 - TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id" json:"tenant_id"` // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id - UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:资源上传者;用于审计与权限控制" json:"user_id"` // 用户ID:资源上传者;用于审计与权限控制 - Type consts.MediaAssetType `gorm:"column:type;type:character varying(32);not null;default:video;comment:资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等)" json:"type"` // 资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等) - Status consts.MediaAssetStatus `gorm:"column:status;type:character varying(32);not null;default:uploaded;comment:处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供" json:"status"` // 处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供 - Provider string `gorm:"column:provider;type:character varying(64);not null;comment:存储提供方:例如 s3/minio/oss;便于多存储扩展" json:"provider"` // 存储提供方:例如 s3/minio/oss;便于多存储扩展 - Bucket string `gorm:"column:bucket;type:character varying(128);not null;comment:存储桶:对象所在 bucket;与 provider 组合确定存储定位" json:"bucket"` // 存储桶:对象所在 bucket;与 provider 组合确定存储定位 - ObjectKey string `gorm:"column:object_key;type:character varying(512);not null;comment:对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发)" json:"object_key"` // 对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发) - Meta types.JSON `gorm:"column:meta;type:jsonb;not null;default:{};comment:元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控" json:"meta"` // 元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控 - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone;comment:软删除时间:非空表示已删除;对外接口需过滤" json:"deleted_at"` // 软删除时间:非空表示已删除;对外接口需过滤 - CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now();用于审计与排序" json:"created_at"` // 创建时间:默认 now();用于审计与排序 - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now();更新状态/元数据时写入" json:"updated_at"` // 更新时间:默认 now();更新状态/元数据时写入 + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增;仅用于内部关联" json:"id"` // 主键ID:自增;仅用于内部关联 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id" json:"tenant_id"` // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:资源上传者;用于审计与权限控制" json:"user_id"` // 用户ID:资源上传者;用于审计与权限控制 + Type consts.MediaAssetType `gorm:"column:type;type:character varying(32);not null;default:video;comment:资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等)" json:"type"` // 资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等) + Status consts.MediaAssetStatus `gorm:"column:status;type:character varying(32);not null;default:uploaded;comment:处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供" json:"status"` // 处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供 + Provider string `gorm:"column:provider;type:character varying(64);not null;comment:存储提供方:例如 s3/minio/oss;便于多存储扩展" json:"provider"` // 存储提供方:例如 s3/minio/oss;便于多存储扩展 + Bucket string `gorm:"column:bucket;type:character varying(128);not null;comment:存储桶:对象所在 bucket;与 provider 组合确定存储定位" json:"bucket"` // 存储桶:对象所在 bucket;与 provider 组合确定存储定位 + ObjectKey string `gorm:"column:object_key;type:character varying(512);not null;comment:对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发)" json:"object_key"` // 对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发) + Meta types.JSON `gorm:"column:meta;type:jsonb;not null;default:{};comment:元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控" json:"meta"` // 元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控 + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone;comment:软删除时间:非空表示已删除;对外接口需过滤" json:"deleted_at"` // 软删除时间:非空表示已删除;对外接口需过滤 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now();用于审计与排序" json:"created_at"` // 创建时间:默认 now();用于审计与排序 + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now();更新状态/元数据时写入" json:"updated_at"` // 更新时间:默认 now();更新状态/元数据时写入 + Variant consts.MediaAssetVariant `gorm:"column:variant;type:character varying(32);not null;default:main;comment:产物类型:main/preview;用于强制试看资源必须绑定独立产物,避免用正片绕过" json:"variant"` // 产物类型:main/preview;用于强制试看资源必须绑定独立产物,避免用正片绕过 + SourceAssetID int64 `gorm:"column:source_asset_id;type:bigint;comment:派生来源资源ID:preview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系" json:"source_asset_id"` // 派生来源资源ID:preview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系 } // Quick operations without importing query package diff --git a/backend/database/models/media_assets.query.gen.go b/backend/database/models/media_assets.query.gen.go index 6ad11ea..2a69d96 100644 --- a/backend/database/models/media_assets.query.gen.go +++ b/backend/database/models/media_assets.query.gen.go @@ -37,6 +37,8 @@ func newMediaAsset(db *gorm.DB, opts ...gen.DOOption) mediaAssetQuery { _mediaAssetQuery.DeletedAt = field.NewField(tableName, "deleted_at") _mediaAssetQuery.CreatedAt = field.NewTime(tableName, "created_at") _mediaAssetQuery.UpdatedAt = field.NewTime(tableName, "updated_at") + _mediaAssetQuery.Variant = field.NewField(tableName, "variant") + _mediaAssetQuery.SourceAssetID = field.NewInt64(tableName, "source_asset_id") _mediaAssetQuery.fillFieldMap() @@ -46,19 +48,21 @@ func newMediaAsset(db *gorm.DB, opts ...gen.DOOption) mediaAssetQuery { type mediaAssetQuery struct { mediaAssetQueryDo mediaAssetQueryDo - ALL field.Asterisk - ID field.Int64 // 主键ID:自增;仅用于内部关联 - TenantID field.Int64 // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id - UserID field.Int64 // 用户ID:资源上传者;用于审计与权限控制 - Type field.Field // 资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等) - Status field.Field // 处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供 - Provider field.String // 存储提供方:例如 s3/minio/oss;便于多存储扩展 - Bucket field.String // 存储桶:对象所在 bucket;与 provider 组合确定存储定位 - ObjectKey field.String // 对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发) - Meta field.JSONB // 元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控 - DeletedAt field.Field // 软删除时间:非空表示已删除;对外接口需过滤 - CreatedAt field.Time // 创建时间:默认 now();用于审计与排序 - UpdatedAt field.Time // 更新时间:默认 now();更新状态/元数据时写入 + ALL field.Asterisk + ID field.Int64 // 主键ID:自增;仅用于内部关联 + TenantID field.Int64 // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + UserID field.Int64 // 用户ID:资源上传者;用于审计与权限控制 + Type field.Field // 资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等) + Status field.Field // 处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供 + Provider field.String // 存储提供方:例如 s3/minio/oss;便于多存储扩展 + Bucket field.String // 存储桶:对象所在 bucket;与 provider 组合确定存储定位 + ObjectKey field.String // 对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发) + Meta field.JSONB // 元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控 + DeletedAt field.Field // 软删除时间:非空表示已删除;对外接口需过滤 + CreatedAt field.Time // 创建时间:默认 now();用于审计与排序 + UpdatedAt field.Time // 更新时间:默认 now();更新状态/元数据时写入 + Variant field.Field // 产物类型:main/preview;用于强制试看资源必须绑定独立产物,避免用正片绕过 + SourceAssetID field.Int64 // 派生来源资源ID:preview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系 fieldMap map[string]field.Expr } @@ -87,6 +91,8 @@ func (m *mediaAssetQuery) updateTableName(table string) *mediaAssetQuery { m.DeletedAt = field.NewField(table, "deleted_at") m.CreatedAt = field.NewTime(table, "created_at") m.UpdatedAt = field.NewTime(table, "updated_at") + m.Variant = field.NewField(table, "variant") + m.SourceAssetID = field.NewInt64(table, "source_asset_id") m.fillFieldMap() @@ -119,7 +125,7 @@ func (m *mediaAssetQuery) GetFieldByName(fieldName string) (field.OrderExpr, boo } func (m *mediaAssetQuery) fillFieldMap() { - m.fieldMap = make(map[string]field.Expr, 12) + m.fieldMap = make(map[string]field.Expr, 14) m.fieldMap["id"] = m.ID m.fieldMap["tenant_id"] = m.TenantID m.fieldMap["user_id"] = m.UserID @@ -132,6 +138,8 @@ func (m *mediaAssetQuery) fillFieldMap() { m.fieldMap["deleted_at"] = m.DeletedAt m.fieldMap["created_at"] = m.CreatedAt m.fieldMap["updated_at"] = m.UpdatedAt + m.fieldMap["variant"] = m.Variant + m.fieldMap["source_asset_id"] = m.SourceAssetID } func (m mediaAssetQuery) clone(db *gorm.DB) mediaAssetQuery { diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 35dfe71..1238b29 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -2793,6 +2793,17 @@ const docTemplate = `{ "MediaAssetTypeImage" ] }, + "consts.MediaAssetVariant": { + "type": "string", + "enum": [ + "main", + "preview" + ], + "x-enum-varnames": [ + "MediaAssetVariantMain", + "MediaAssetVariantPreview" + ] + }, "consts.OrderStatus": { "type": "string", "enum": [ @@ -3056,7 +3067,11 @@ const docTemplate = `{ }, "variant": { "description": "Variant indicates whether this asset is a main or preview product.\nAllowed: main/preview; default is main.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetVariant" + } + ] } } }, @@ -4061,6 +4076,10 @@ const docTemplate = `{ "description": "存储提供方:例如 s3/minio/oss;便于多存储扩展", "type": "string" }, + "source_asset_id": { + "description": "派生来源资源ID:preview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系", + "type": "integer" + }, "status": { "description": "处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供", "allOf": [ @@ -4088,6 +4107,14 @@ const docTemplate = `{ "user_id": { "description": "用户ID:资源上传者;用于审计与权限控制", "type": "integer" + }, + "variant": { + "description": "产物类型:main/preview;用于强制试看资源必须绑定独立产物,避免用正片绕过", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetVariant" + } + ] } } }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 827f663..9d855e3 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -2787,6 +2787,17 @@ "MediaAssetTypeImage" ] }, + "consts.MediaAssetVariant": { + "type": "string", + "enum": [ + "main", + "preview" + ], + "x-enum-varnames": [ + "MediaAssetVariantMain", + "MediaAssetVariantPreview" + ] + }, "consts.OrderStatus": { "type": "string", "enum": [ @@ -3050,7 +3061,11 @@ }, "variant": { "description": "Variant indicates whether this asset is a main or preview product.\nAllowed: main/preview; default is main.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetVariant" + } + ] } } }, @@ -4055,6 +4070,10 @@ "description": "存储提供方:例如 s3/minio/oss;便于多存储扩展", "type": "string" }, + "source_asset_id": { + "description": "派生来源资源ID:preview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系", + "type": "integer" + }, "status": { "description": "处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供", "allOf": [ @@ -4082,6 +4101,14 @@ "user_id": { "description": "用户ID:资源上传者;用于审计与权限控制", "type": "integer" + }, + "variant": { + "description": "产物类型:main/preview;用于强制试看资源必须绑定独立产物,避免用正片绕过", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetVariant" + } + ] } } }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 344d219..2ecc4bb 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -84,6 +84,14 @@ definitions: - MediaAssetTypeVideo - MediaAssetTypeAudio - MediaAssetTypeImage + consts.MediaAssetVariant: + enum: + - main + - preview + type: string + x-enum-varnames: + - MediaAssetVariantMain + - MediaAssetVariantPreview consts.OrderStatus: enum: - created @@ -299,10 +307,11 @@ definitions: Used to decide processing pipeline and validation rules; required. type: string variant: + allOf: + - $ref: '#/definitions/consts.MediaAssetVariant' description: |- Variant indicates whether this asset is a main or preview product. Allowed: main/preview; default is main. - type: string type: object dto.AdminMediaAssetUploadInitResponse: properties: @@ -976,6 +985,9 @@ definitions: provider: description: 存储提供方:例如 s3/minio/oss;便于多存储扩展 type: string + source_asset_id: + description: 派生来源资源ID:preview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系 + type: integer status: allOf: - $ref: '#/definitions/consts.MediaAssetStatus' @@ -993,6 +1005,10 @@ definitions: user_id: description: 用户ID:资源上传者;用于审计与权限控制 type: integer + variant: + allOf: + - $ref: '#/definitions/consts.MediaAssetVariant' + description: 产物类型:main/preview;用于强制试看资源必须绑定独立产物,避免用正片绕过 type: object models.Order: properties: diff --git a/backend/pkg/consts/consts.gen.go b/backend/pkg/consts/consts.gen.go index 7e93c95..f32bb10 100644 --- a/backend/pkg/consts/consts.gen.go +++ b/backend/pkg/consts/consts.gen.go @@ -1343,6 +1343,166 @@ func (x NullMediaAssetTypeStr) Value() (driver.Value, error) { return x.MediaAssetType.String(), nil } +const ( + // MediaAssetVariantMain is a MediaAssetVariant of type main. + MediaAssetVariantMain MediaAssetVariant = "main" + // MediaAssetVariantPreview is a MediaAssetVariant of type preview. + MediaAssetVariantPreview MediaAssetVariant = "preview" +) + +var ErrInvalidMediaAssetVariant = fmt.Errorf("not a valid MediaAssetVariant, try [%s]", strings.Join(_MediaAssetVariantNames, ", ")) + +var _MediaAssetVariantNames = []string{ + string(MediaAssetVariantMain), + string(MediaAssetVariantPreview), +} + +// MediaAssetVariantNames returns a list of possible string values of MediaAssetVariant. +func MediaAssetVariantNames() []string { + tmp := make([]string, len(_MediaAssetVariantNames)) + copy(tmp, _MediaAssetVariantNames) + return tmp +} + +// MediaAssetVariantValues returns a list of the values for MediaAssetVariant +func MediaAssetVariantValues() []MediaAssetVariant { + return []MediaAssetVariant{ + MediaAssetVariantMain, + MediaAssetVariantPreview, + } +} + +// String implements the Stringer interface. +func (x MediaAssetVariant) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x MediaAssetVariant) IsValid() bool { + _, err := ParseMediaAssetVariant(string(x)) + return err == nil +} + +var _MediaAssetVariantValue = map[string]MediaAssetVariant{ + "main": MediaAssetVariantMain, + "preview": MediaAssetVariantPreview, +} + +// ParseMediaAssetVariant attempts to convert a string to a MediaAssetVariant. +func ParseMediaAssetVariant(name string) (MediaAssetVariant, error) { + if x, ok := _MediaAssetVariantValue[name]; ok { + return x, nil + } + return MediaAssetVariant(""), fmt.Errorf("%s is %w", name, ErrInvalidMediaAssetVariant) +} + +var errMediaAssetVariantNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *MediaAssetVariant) Scan(value interface{}) (err error) { + if value == nil { + *x = MediaAssetVariant("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseMediaAssetVariant(v) + case []byte: + *x, err = ParseMediaAssetVariant(string(v)) + case MediaAssetVariant: + *x = v + case *MediaAssetVariant: + if v == nil { + return errMediaAssetVariantNilPtr + } + *x = *v + case *string: + if v == nil { + return errMediaAssetVariantNilPtr + } + *x, err = ParseMediaAssetVariant(*v) + default: + return errors.New("invalid type for MediaAssetVariant") + } + + return +} + +// Value implements the driver Valuer interface. +func (x MediaAssetVariant) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *MediaAssetVariant) Set(val string) error { + v, err := ParseMediaAssetVariant(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *MediaAssetVariant) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *MediaAssetVariant) Type() string { + return "MediaAssetVariant" +} + +type NullMediaAssetVariant struct { + MediaAssetVariant MediaAssetVariant + Valid bool +} + +func NewNullMediaAssetVariant(val interface{}) (x NullMediaAssetVariant) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullMediaAssetVariant) Scan(value interface{}) (err error) { + if value == nil { + x.MediaAssetVariant, x.Valid = MediaAssetVariant(""), false + return + } + + err = x.MediaAssetVariant.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullMediaAssetVariant) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.MediaAssetVariant), nil +} + +type NullMediaAssetVariantStr struct { + NullMediaAssetVariant +} + +func NewNullMediaAssetVariantStr(val interface{}) (x NullMediaAssetVariantStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullMediaAssetVariantStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.MediaAssetVariant.String(), nil +} + const ( // OrderStatusCreated is a OrderStatus of type created. OrderStatusCreated OrderStatus = "created" diff --git a/backend/pkg/consts/consts.go b/backend/pkg/consts/consts.go index 6bb8e5e..5dd626d 100644 --- a/backend/pkg/consts/consts.go +++ b/backend/pkg/consts/consts.go @@ -185,6 +185,32 @@ func MediaAssetStatusItems() []requests.KV { return items } +// swagger:enum MediaAssetVariant +// ENUM( main, preview ) +type MediaAssetVariant string + +// Description returns the Chinese label for the specific enum value. +func (t MediaAssetVariant) Description() string { + switch t { + case MediaAssetVariantMain: + return "正片产物" + case MediaAssetVariantPreview: + return "试看产物" + default: + return "未知产物" + } +} + +// MediaAssetVariantItems returns the KV list for FE dropdowns. +func MediaAssetVariantItems() []requests.KV { + values := MediaAssetVariantValues() + items := make([]requests.KV, 0, len(values)) + for _, v := range values { + items = append(items, requests.NewKV(string(v), v.Description())) + } + return items +} + // contents // swagger:enum ContentStatus