feat: 添加媒体资产变体和来源ID字段,支持预览产品与主资产的关联
This commit is contained in:
@@ -7,6 +7,14 @@ type AdminMediaAssetUploadInitForm struct {
|
||||
// Type is the media asset type (video/audio/image).
|
||||
// Used to decide processing pipeline and validation rules; required.
|
||||
Type string `json:"type,omitempty"`
|
||||
|
||||
// Variant indicates whether this asset is a main or preview product.
|
||||
// Allowed: main/preview; default is main.
|
||||
Variant string `json:"variant,omitempty"`
|
||||
|
||||
// SourceAssetID links a preview product to its main asset; only meaningful when variant=preview.
|
||||
SourceAssetID *int64 `json:"source_asset_id,omitempty"`
|
||||
|
||||
// ContentType is the MIME type reported by the client (e.g. video/mp4); optional.
|
||||
// Server should not fully trust it, but can use it as a hint for validation/logging.
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
|
||||
@@ -34,6 +34,16 @@ type ContentDetailResult struct {
|
||||
HasAccess bool
|
||||
}
|
||||
|
||||
func requiredMediaAssetVariantForRole(role consts.ContentAssetRole) string {
|
||||
switch role {
|
||||
case consts.ContentAssetRolePreview:
|
||||
return mediaAssetVariantPreview
|
||||
default:
|
||||
// main/cover 一律要求 main 产物,避免误把 preview 绑定成正片/封面。
|
||||
return mediaAssetVariantMain
|
||||
}
|
||||
}
|
||||
|
||||
func (s *content) Create(ctx context.Context, tenantID, userID int64, form *dto.ContentCreateForm) (*models.Content, error) {
|
||||
log.WithFields(log.Fields{
|
||||
"tenant_id": tenantID,
|
||||
@@ -215,6 +225,60 @@ func (s *content) AttachAsset(ctx context.Context, tenantID, userID, contentID,
|
||||
return nil, errorx.ErrPreconditionFailed.WithMsg("media asset not ready")
|
||||
}
|
||||
|
||||
// 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
|
||||
if variant == "" {
|
||||
variant = mediaAssetVariantMain
|
||||
}
|
||||
requiredVariant := requiredMediaAssetVariantForRole(role)
|
||||
if variant != requiredVariant {
|
||||
return nil, errorx.ErrPreconditionFailed.WithMsg("media asset variant mismatch")
|
||||
}
|
||||
// 关联规则:preview 产物必须声明来源 main;main/cover 不允许带来源。
|
||||
if role == consts.ContentAssetRolePreview {
|
||||
if assetRow.SourceAssetID == nil || *assetRow.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 {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errorx.ErrRecordNotFound.WithMsg("preview source asset not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
srcVariant := srcRow.Variant
|
||||
if srcVariant == "" {
|
||||
srcVariant = mediaAssetVariantMain
|
||||
}
|
||||
if srcVariant != mediaAssetVariantMain {
|
||||
return nil, errorx.ErrPreconditionFailed.WithMsg("preview source asset must be main variant")
|
||||
}
|
||||
} else {
|
||||
if assetRow.SourceAssetID != nil && *assetRow.SourceAssetID > 0 {
|
||||
return nil, errorx.ErrPreconditionFailed.WithMsg("main/cover asset must not have source_asset_id")
|
||||
}
|
||||
}
|
||||
|
||||
m := &models.ContentAsset{
|
||||
TenantID: tenantID,
|
||||
UserID: userID,
|
||||
|
||||
@@ -31,6 +31,11 @@ import (
|
||||
// @provider
|
||||
type mediaAsset struct{}
|
||||
|
||||
const (
|
||||
mediaAssetVariantMain = "main"
|
||||
mediaAssetVariantPreview = "preview"
|
||||
)
|
||||
|
||||
func mediaAssetTransitionAllowed(from, to consts.MediaAssetStatus) bool {
|
||||
switch from {
|
||||
case consts.MediaAssetStatusUploaded:
|
||||
@@ -77,6 +82,50 @@ 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
|
||||
}
|
||||
if variant != mediaAssetVariantMain && variant != mediaAssetVariantPreview {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("invalid variant")
|
||||
}
|
||||
|
||||
var sourceAssetID int64
|
||||
if form.SourceAssetID != nil {
|
||||
sourceAssetID = *form.SourceAssetID
|
||||
}
|
||||
if variant == mediaAssetVariantMain {
|
||||
if sourceAssetID != 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("source_asset_id is only allowed for preview variant")
|
||||
}
|
||||
} else {
|
||||
// preview variant: requires a source main asset for traceability.
|
||||
if sourceAssetID <= 0 {
|
||||
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 {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errorx.ErrRecordNotFound.WithMsg("source media asset not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
srcVariant := srcRow.Variant
|
||||
if srcVariant == "" {
|
||||
srcVariant = mediaAssetVariantMain
|
||||
}
|
||||
if srcVariant != mediaAssetVariantMain {
|
||||
return nil, errorx.ErrPreconditionFailed.WithMsg("source asset must be main variant")
|
||||
}
|
||||
}
|
||||
|
||||
objectKey, err := newObjectKey(tenantID, operatorUserID, typ, now)
|
||||
if err != nil {
|
||||
return nil, pkgerrors.Wrap(err, "generate object_key failed")
|
||||
@@ -113,11 +162,27 @@ func (s *mediaAsset) AdminUploadInit(ctx context.Context, tenantID, operatorUser
|
||||
return nil, pkgerrors.Wrap(err, "create media asset failed")
|
||||
}
|
||||
|
||||
// variant/source_asset_id 目前为 DB 新增字段;由于 models 为 gen 产物,这里用 SQL 更新列值。
|
||||
updates := map[string]any{
|
||||
"variant": variant,
|
||||
}
|
||||
if sourceAssetID > 0 {
|
||||
updates["source_asset_id"] = sourceAssetID
|
||||
}
|
||||
if err := _db.WithContext(ctx).
|
||||
Model(&models.MediaAsset{}).
|
||||
Where("id = ? AND tenant_id = ?", m.ID, tenantID).
|
||||
Updates(updates).Error; err != nil {
|
||||
return nil, pkgerrors.Wrap(err, "update media asset variant/source_asset_id failed")
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"user_id": operatorUserID,
|
||||
"asset_id": m.ID,
|
||||
"type": typ,
|
||||
"variant": variant,
|
||||
"source_id": sourceAssetID,
|
||||
"object_key": objectKey,
|
||||
}).Info("services.media_asset.admin.upload_init")
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE media_assets
|
||||
ADD COLUMN IF NOT EXISTS variant varchar(32) NOT NULL DEFAULT 'main';
|
||||
|
||||
-- 回填历史数据:老数据一律视为 main。
|
||||
UPDATE media_assets
|
||||
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'));
|
||||
|
||||
COMMENT ON COLUMN media_assets.variant IS '产物类型:main/preview;用于强制试看资源必须绑定独立产物,避免用正片绕过';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_media_assets_tenant_variant ON media_assets (tenant_id, variant);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE media_assets
|
||||
ADD COLUMN IF NOT EXISTS source_asset_id bigint;
|
||||
|
||||
COMMENT ON COLUMN media_assets.source_asset_id IS '派生来源资源ID:preview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_media_assets_tenant_source_asset_id ON media_assets (tenant_id, source_asset_id);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP INDEX IF EXISTS ix_media_assets_tenant_source_asset_id;
|
||||
ALTER TABLE media_assets DROP COLUMN IF EXISTS source_asset_id;
|
||||
-- +goose StatementEnd
|
||||
|
||||
@@ -3046,9 +3046,17 @@ const docTemplate = `{
|
||||
"description": "SHA256 is the hex-encoded sha256 of the file; optional.\nUsed for deduplication/audit; server may validate it later during upload-complete.",
|
||||
"type": "string"
|
||||
},
|
||||
"source_asset_id": {
|
||||
"description": "SourceAssetID links a preview product to its main asset; only meaningful when variant=preview.",
|
||||
"type": "integer"
|
||||
},
|
||||
"type": {
|
||||
"description": "Type is the media asset type (video/audio/image).\nUsed to decide processing pipeline and validation rules; required.",
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"description": "Variant indicates whether this asset is a main or preview product.\nAllowed: main/preview; default is main.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3040,9 +3040,17 @@
|
||||
"description": "SHA256 is the hex-encoded sha256 of the file; optional.\nUsed for deduplication/audit; server may validate it later during upload-complete.",
|
||||
"type": "string"
|
||||
},
|
||||
"source_asset_id": {
|
||||
"description": "SourceAssetID links a preview product to its main asset; only meaningful when variant=preview.",
|
||||
"type": "integer"
|
||||
},
|
||||
"type": {
|
||||
"description": "Type is the media asset type (video/audio/image).\nUsed to decide processing pipeline and validation rules; required.",
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"description": "Variant indicates whether this asset is a main or preview product.\nAllowed: main/preview; default is main.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -289,11 +289,20 @@ definitions:
|
||||
SHA256 is the hex-encoded sha256 of the file; optional.
|
||||
Used for deduplication/audit; server may validate it later during upload-complete.
|
||||
type: string
|
||||
source_asset_id:
|
||||
description: SourceAssetID links a preview product to its main asset; only
|
||||
meaningful when variant=preview.
|
||||
type: integer
|
||||
type:
|
||||
description: |-
|
||||
Type is the media asset type (video/audio/image).
|
||||
Used to decide processing pipeline and validation rules; required.
|
||||
type: string
|
||||
variant:
|
||||
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:
|
||||
|
||||
@@ -133,6 +133,8 @@
|
||||
- 用 main 资源绑定 preview 被拒绝;
|
||||
- preview 秒数只对 preview 下发生效。
|
||||
|
||||
> 备注(已选定实现方式):本项目采用 **新增列 `media_assets.variant`**,并对取值做 CHECK 约束(main/preview)。
|
||||
|
||||
## Epic D:异步退款/风控预留(当前 `refunding` 未使用)
|
||||
|
||||
### D1(P2, State Machine)引入 `refunding` 并定义状态迁移
|
||||
@@ -175,4 +177,3 @@
|
||||
3) C1 → C2(把资源下发安全化,再强制 preview 独立产物)
|
||||
4) E1(审计增强,避免后续追溯成本)
|
||||
5) D1(如确需异步退款/风控,再引入)
|
||||
|
||||
|
||||
@@ -131,11 +131,26 @@ Authorization: Bearer {{ token }}
|
||||
|
||||
{
|
||||
"type": "video",
|
||||
"variant": "main",
|
||||
"content_type": "video/mp4",
|
||||
"file_size": 12345678,
|
||||
"sha256": ""
|
||||
}
|
||||
|
||||
### Tenant Admin - MediaAsset upload init (preview product)
|
||||
POST {{ host }}/t/{{ tenantCode }}/v1/admin/media_assets/upload_init
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{ token }}
|
||||
|
||||
{
|
||||
"type": "video",
|
||||
"variant": "preview",
|
||||
"source_asset_id": 1,
|
||||
"content_type": "video/mp4",
|
||||
"file_size": 12345,
|
||||
"sha256": ""
|
||||
}
|
||||
|
||||
### Tenant Admin - MediaAsset upload complete (uploaded -> processing)
|
||||
@assetID = 1
|
||||
POST {{ host }}/t/{{ tenantCode }}/v1/admin/media_assets/{{ assetID }}/upload_complete
|
||||
|
||||
Reference in New Issue
Block a user