From ad82de3939f67f9709f889060c8fb86bac0a1fa4 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 22 Dec 2025 17:56:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E5=8F=98=E4=BD=93=E5=92=8C=E6=9D=A5=E6=BA=90?= =?UTF-8?q?ID=E5=AD=97=E6=AE=B5=EF=BC=8C=E6=94=AF=E6=8C=81=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E4=BA=A7=E5=93=81=E4=B8=8E=E4=B8=BB=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E7=9A=84=E5=85=B3=E8=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/http/tenant/dto/media_asset_admin.go | 8 +++ backend/app/services/content.go | 64 ++++++++++++++++++ backend/app/services/media_asset.go | 65 +++++++++++++++++++ .../20251222174000_media_assets_variant.sql | 27 ++++++++ ...222175500_media_assets_source_asset_id.sql | 16 +++++ backend/docs/docs.go | 8 +++ backend/docs/swagger.json | 8 +++ backend/docs/swagger.yaml | 9 +++ backend/specs/spec01-backlog.md | 3 +- backend/tests/tenant.http | 15 +++++ 10 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 backend/database/migrations/20251222174000_media_assets_variant.sql create mode 100644 backend/database/migrations/20251222175500_media_assets_source_asset_id.sql diff --git a/backend/app/http/tenant/dto/media_asset_admin.go b/backend/app/http/tenant/dto/media_asset_admin.go index b4bd9e6..d2609ae 100644 --- a/backend/app/http/tenant/dto/media_asset_admin.go +++ b/backend/app/http/tenant/dto/media_asset_admin.go @@ -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"` diff --git a/backend/app/services/content.go b/backend/app/services/content.go index 5362c59..9f745b7 100644 --- a/backend/app/services/content.go +++ b/backend/app/services/content.go @@ -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, diff --git a/backend/app/services/media_asset.go b/backend/app/services/media_asset.go index a3003df..934a4d6 100644 --- a/backend/app/services/media_asset.go +++ b/backend/app/services/media_asset.go @@ -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") diff --git a/backend/database/migrations/20251222174000_media_assets_variant.sql b/backend/database/migrations/20251222174000_media_assets_variant.sql new file mode 100644 index 0000000..1aa2b3f --- /dev/null +++ b/backend/database/migrations/20251222174000_media_assets_variant.sql @@ -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 + diff --git a/backend/database/migrations/20251222175500_media_assets_source_asset_id.sql b/backend/database/migrations/20251222175500_media_assets_source_asset_id.sql new file mode 100644 index 0000000..4b67008 --- /dev/null +++ b/backend/database/migrations/20251222175500_media_assets_source_asset_id.sql @@ -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 + diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 153a0a7..35dfe71 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -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" } } }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index c23a7c5..827f663 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -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" } } }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index ad36c6d..344d219 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -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: diff --git a/backend/specs/spec01-backlog.md b/backend/specs/spec01-backlog.md index 5244dce..8a611b7 100644 --- a/backend/specs/spec01-backlog.md +++ b/backend/specs/spec01-backlog.md @@ -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(如确需异步退款/风控,再引入) - diff --git a/backend/tests/tenant.http b/backend/tests/tenant.http index 14ce6c1..cc700d5 100644 --- a/backend/tests/tenant.http +++ b/backend/tests/tenant.http @@ -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