feat: Introduce MediaAssetVariant for better asset management

- Added MediaAssetVariant enum with values 'main' and 'preview'.
- Updated media asset service logic to utilize MediaAssetVariant for variant handling.
- Refactored database models and queries to include variant and source_asset_id fields.
- Enhanced validation for asset variants in upload and processing functions.
- Updated Swagger documentation to reflect new variant structure and descriptions.
- Implemented necessary database migrations to support the new variant constraints.
This commit is contained in:
2025-12-22 19:27:31 +08:00
parent d04e2ee693
commit 2cc823d3a8
14 changed files with 439 additions and 171 deletions

View File

@@ -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"`

View File

@@ -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=previewmain/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 产物必须声明来源 mainmain/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")
}
}

View File

@@ -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 {

View File

@@ -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() {
})
})
}

View File

@@ -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)
}
}