Compare commits
3 Commits
335a546aab
...
0d63ecc9c2
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d63ecc9c2 | |||
| ff3bd61929 | |||
| ad82de3939 |
@@ -7,6 +7,14 @@ type AdminMediaAssetUploadInitForm struct {
|
|||||||
// Type is the media asset type (video/audio/image).
|
// Type is the media asset type (video/audio/image).
|
||||||
// Used to decide processing pipeline and validation rules; required.
|
// Used to decide processing pipeline and validation rules; required.
|
||||||
Type string `json:"type,omitempty"`
|
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.
|
// 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.
|
// Server should not fully trust it, but can use it as a hint for validation/logging.
|
||||||
ContentType string `json:"content_type,omitempty"`
|
ContentType string `json:"content_type,omitempty"`
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ type ContentDetailResult struct {
|
|||||||
HasAccess bool
|
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) {
|
func (s *content) Create(ctx context.Context, tenantID, userID int64, form *dto.ContentCreateForm) (*models.Content, error) {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"tenant_id": tenantID,
|
"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")
|
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{
|
m := &models.ContentAsset{
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
|||||||
@@ -195,6 +195,54 @@ func (s *ContentTestSuite) Test_AttachAsset() {
|
|||||||
So(m.AssetID, ShouldEqual, asset.ID)
|
So(m.AssetID, ShouldEqual, asset.ID)
|
||||||
So(m.Role, ShouldEqual, consts.ContentAssetRoleMain)
|
So(m.Role, ShouldEqual, consts.ContentAssetRoleMain)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Convey("preview role 只能绑定 preview variant,且必须有 source_asset_id", func() {
|
||||||
|
previewAsset := &models.MediaAsset{
|
||||||
|
TenantID: tenantID,
|
||||||
|
UserID: userID,
|
||||||
|
Type: consts.MediaAssetTypeVideo,
|
||||||
|
Status: consts.MediaAssetStatusReady,
|
||||||
|
Provider: "test",
|
||||||
|
Bucket: "bucket",
|
||||||
|
ObjectKey: "obj-preview",
|
||||||
|
Meta: types.JSON([]byte("{}")),
|
||||||
|
}
|
||||||
|
So(previewAsset.Create(ctx), ShouldBeNil)
|
||||||
|
|
||||||
|
// 标记为 preview 产物,但不设置 source_asset_id,应被拒绝。
|
||||||
|
_, err := s.DB.ExecContext(ctx, "UPDATE media_assets SET variant = 'preview', source_asset_id = NULL WHERE tenant_id = $1 AND id = $2", tenantID, previewAsset.ID)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
|
_, err = Content.AttachAsset(ctx, tenantID, userID, content.ID, previewAsset.ID, consts.ContentAssetRolePreview, 1, now)
|
||||||
|
So(err, ShouldNotBeNil)
|
||||||
|
var appErr *errorx.AppError
|
||||||
|
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||||
|
So(appErr.Code, ShouldEqual, errorx.ErrPreconditionFailed.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("main role 绑定 preview variant 应被拒绝", func() {
|
||||||
|
previewAsset := &models.MediaAsset{
|
||||||
|
TenantID: tenantID,
|
||||||
|
UserID: userID,
|
||||||
|
Type: consts.MediaAssetTypeVideo,
|
||||||
|
Status: consts.MediaAssetStatusReady,
|
||||||
|
Provider: "test",
|
||||||
|
Bucket: "bucket",
|
||||||
|
ObjectKey: "obj-preview2",
|
||||||
|
Meta: types.JSON([]byte("{}")),
|
||||||
|
}
|
||||||
|
So(previewAsset.Create(ctx), ShouldBeNil)
|
||||||
|
|
||||||
|
// 将该资源标记为 preview 产物,并设置一个合法来源(指向已有 main 资源 asset)。
|
||||||
|
_, err := s.DB.ExecContext(ctx, "UPDATE media_assets SET variant = 'preview', source_asset_id = $1 WHERE tenant_id = $2 AND id = $3", asset.ID, tenantID, previewAsset.ID)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
|
_, err = Content.AttachAsset(ctx, tenantID, userID, content.ID, previewAsset.ID, consts.ContentAssetRoleMain, 1, now)
|
||||||
|
So(err, ShouldNotBeNil)
|
||||||
|
var appErr *errorx.AppError
|
||||||
|
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||||
|
So(appErr.Code, ShouldEqual, errorx.ErrPreconditionFailed.Code)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ import (
|
|||||||
// @provider
|
// @provider
|
||||||
type mediaAsset struct{}
|
type mediaAsset struct{}
|
||||||
|
|
||||||
|
const (
|
||||||
|
mediaAssetVariantMain = "main"
|
||||||
|
mediaAssetVariantPreview = "preview"
|
||||||
|
)
|
||||||
|
|
||||||
func mediaAssetTransitionAllowed(from, to consts.MediaAssetStatus) bool {
|
func mediaAssetTransitionAllowed(from, to consts.MediaAssetStatus) bool {
|
||||||
switch from {
|
switch from {
|
||||||
case consts.MediaAssetStatusUploaded:
|
case consts.MediaAssetStatusUploaded:
|
||||||
@@ -77,6 +82,50 @@ func (s *mediaAsset) AdminUploadInit(ctx context.Context, tenantID, operatorUser
|
|||||||
return nil, errorx.ErrInvalidParameter.WithMsg("invalid type")
|
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)
|
objectKey, err := newObjectKey(tenantID, operatorUserID, typ, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, pkgerrors.Wrap(err, "generate object_key failed")
|
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")
|
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{
|
logrus.WithFields(logrus.Fields{
|
||||||
"tenant_id": tenantID,
|
"tenant_id": tenantID,
|
||||||
"user_id": operatorUserID,
|
"user_id": operatorUserID,
|
||||||
"asset_id": m.ID,
|
"asset_id": m.ID,
|
||||||
"type": typ,
|
"type": typ,
|
||||||
|
"variant": variant,
|
||||||
|
"source_id": sourceAssetID,
|
||||||
"object_key": objectKey,
|
"object_key": objectKey,
|
||||||
}).Info("services.media_asset.admin.upload_init")
|
}).Info("services.media_asset.admin.upload_init")
|
||||||
|
|
||||||
|
|||||||
109
backend/app/services/media_asset_test.go
Normal file
109
backend/app/services/media_asset_test.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"quyun/v2/app/commands/testx"
|
||||||
|
"quyun/v2/app/errorx"
|
||||||
|
tenant_dto "quyun/v2/app/http/tenant/dto"
|
||||||
|
"quyun/v2/database"
|
||||||
|
"quyun/v2/database/models"
|
||||||
|
"quyun/v2/pkg/consts"
|
||||||
|
|
||||||
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
|
||||||
|
_ "go.ipao.vip/atom"
|
||||||
|
"go.ipao.vip/atom/contracts"
|
||||||
|
"go.ipao.vip/gen/types"
|
||||||
|
"go.uber.org/dig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaAssetTestSuiteInjectParams struct {
|
||||||
|
dig.In
|
||||||
|
|
||||||
|
DB *sql.DB
|
||||||
|
Initials []contracts.Initial `group:"initials"` // nolint:structcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaAssetTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
MediaAssetTestSuiteInjectParams
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_MediaAsset(t *testing.T) {
|
||||||
|
providers := testx.Default().With(Provide)
|
||||||
|
|
||||||
|
testx.Serve(providers, t, func(p MediaAssetTestSuiteInjectParams) {
|
||||||
|
suite.Run(t, &MediaAssetTestSuite{MediaAssetTestSuiteInjectParams: p})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaAssetTestSuite) Test_AdminUploadInit_VariantAndSource() {
|
||||||
|
Convey("MediaAsset.AdminUploadInit variant/source_asset_id", s.T(), func() {
|
||||||
|
ctx := s.T().Context()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tenantID := int64(1)
|
||||||
|
userID := int64(2)
|
||||||
|
|
||||||
|
database.Truncate(ctx, s.DB, models.TableNameMediaAsset)
|
||||||
|
|
||||||
|
Convey("main variant 不允许 source_asset_id", func() {
|
||||||
|
src := int64(123)
|
||||||
|
_, err := MediaAsset.AdminUploadInit(ctx, tenantID, userID, &tenant_dto.AdminMediaAssetUploadInitForm{
|
||||||
|
Type: "video",
|
||||||
|
Variant: "main",
|
||||||
|
SourceAssetID: &src,
|
||||||
|
}, now)
|
||||||
|
So(err, ShouldNotBeNil)
|
||||||
|
var appErr *errorx.AppError
|
||||||
|
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||||
|
So(appErr.Code, ShouldEqual, errorx.ErrInvalidParameter.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("preview variant 必须带 source_asset_id", func() {
|
||||||
|
_, err := MediaAsset.AdminUploadInit(ctx, tenantID, userID, &tenant_dto.AdminMediaAssetUploadInitForm{
|
||||||
|
Type: "video",
|
||||||
|
Variant: "preview",
|
||||||
|
}, now)
|
||||||
|
So(err, ShouldNotBeNil)
|
||||||
|
var appErr *errorx.AppError
|
||||||
|
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||||
|
So(appErr.Code, ShouldEqual, errorx.ErrInvalidParameter.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("preview variant 的 source_asset_id 必须存在且为 main variant", func() {
|
||||||
|
src := &models.MediaAsset{
|
||||||
|
TenantID: tenantID,
|
||||||
|
UserID: userID,
|
||||||
|
Type: consts.MediaAssetTypeVideo,
|
||||||
|
Status: consts.MediaAssetStatusReady,
|
||||||
|
Provider: "test",
|
||||||
|
Bucket: "b",
|
||||||
|
ObjectKey: "k",
|
||||||
|
Meta: types.JSON([]byte("{}")),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
So(src.Create(ctx), ShouldBeNil)
|
||||||
|
|
||||||
|
// 将来源资源标记为 preview,模拟“来源不是 main”的非法情况。
|
||||||
|
_, err := s.DB.ExecContext(ctx, "UPDATE media_assets SET variant = 'preview' WHERE tenant_id = $1 AND id = $2", tenantID, src.ID)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
|
_, err = MediaAsset.AdminUploadInit(ctx, tenantID, userID, &tenant_dto.AdminMediaAssetUploadInitForm{
|
||||||
|
Type: "video",
|
||||||
|
Variant: "preview",
|
||||||
|
SourceAssetID: &src.ID,
|
||||||
|
}, now)
|
||||||
|
So(err, ShouldNotBeNil)
|
||||||
|
var appErr *errorx.AppError
|
||||||
|
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||||
|
So(appErr.Code, ShouldEqual, errorx.ErrPreconditionFailed.Code)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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.",
|
"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"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"source_asset_id": {
|
||||||
|
"description": "SourceAssetID links a preview product to its main asset; only meaningful when variant=preview.",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"description": "Type is the media asset type (video/audio/image).\nUsed to decide processing pipeline and validation rules; required.",
|
"description": "Type is the media asset type (video/audio/image).\nUsed to decide processing pipeline and validation rules; required.",
|
||||||
"type": "string"
|
"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.",
|
"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"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"source_asset_id": {
|
||||||
|
"description": "SourceAssetID links a preview product to its main asset; only meaningful when variant=preview.",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"description": "Type is the media asset type (video/audio/image).\nUsed to decide processing pipeline and validation rules; required.",
|
"description": "Type is the media asset type (video/audio/image).\nUsed to decide processing pipeline and validation rules; required.",
|
||||||
"type": "string"
|
"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.
|
SHA256 is the hex-encoded sha256 of the file; optional.
|
||||||
Used for deduplication/audit; server may validate it later during upload-complete.
|
Used for deduplication/audit; server may validate it later during upload-complete.
|
||||||
type: string
|
type: string
|
||||||
|
source_asset_id:
|
||||||
|
description: SourceAssetID links a preview product to its main asset; only
|
||||||
|
meaningful when variant=preview.
|
||||||
|
type: integer
|
||||||
type:
|
type:
|
||||||
description: |-
|
description: |-
|
||||||
Type is the media asset type (video/audio/image).
|
Type is the media asset type (video/audio/image).
|
||||||
Used to decide processing pipeline and validation rules; required.
|
Used to decide processing pipeline and validation rules; required.
|
||||||
type: string
|
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
|
type: object
|
||||||
dto.AdminMediaAssetUploadInitResponse:
|
dto.AdminMediaAssetUploadInitResponse:
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
104
backend/llm.gorm_gen.txt
Normal file
104
backend/llm.gorm_gen.txt
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# GORM Gen Library Summary (PostgreSQL Extended Version)
|
||||||
|
|
||||||
|
This document summarizes the capabilities of the GORM Gen code generation tool, specifically focusing on its extended version tailored for PostgreSQL. It covers standard Gen features and the substantial PostgreSQL-specific enhancements for types and field expressions.
|
||||||
|
|
||||||
|
## 1. DAO Interface Generation
|
||||||
|
- **Concept**: Generates type-safe Data Access Object (DAO) interfaces and query code.
|
||||||
|
- **Process**:
|
||||||
|
- **Configuration**: Use `gen.Config` to set output paths, package names, and modes.
|
||||||
|
- **PostgreSQL Enforcement**: The generator explicitly requires a PostgreSQL database connection via `g.UseDB(db)` (checks for "postgres" dialector).
|
||||||
|
- **Model Application**: Automatically maps database tables to Go structs using `g.GenerateAllTable()` or specific tables with `g.GenerateModel()`.
|
||||||
|
- **Output**: Generates DAO interfaces with CRUD methods, query structs, and model structs. Defaults to "Same Package" generation (models and queries in the same directory) for easier usage.
|
||||||
|
- **Usage**: Interact via a global `Q` variable or initialized query instances.
|
||||||
|
|
||||||
|
## 2. Creating Records
|
||||||
|
- **Standard**: `u.WithContext(ctx).Create(&user)`
|
||||||
|
- **Modifiers**: `Select()`, `Omit()` to control fields.
|
||||||
|
- **Batch**: `CreateInBatches()` for bulk inserts.
|
||||||
|
- **Upsert**: Supports `clause.OnConflict` strategies.
|
||||||
|
- **Extended Types**: Seamlessly handles extended types (Arrays, JSONB, Ranges, etc.) during creation.
|
||||||
|
|
||||||
|
## 3. Querying Data
|
||||||
|
- **Retrieval**: `First()`, `Take()`, `Last()`, `Find()`.
|
||||||
|
- **Conditions**: Type-safe methods (`Eq`, `Neq`, `Gt`, `Lt`, `Like`, `In`).
|
||||||
|
- **PostgreSQL Specific Conditions**:
|
||||||
|
- **JSON/JSONB**:
|
||||||
|
- `HasKey("key")` (operator `?`)
|
||||||
|
- `HasAllKeys("k1", "k2")` (operator `?&`)
|
||||||
|
- `KeyEq("path.to.key", value)` (extracts path and compares).
|
||||||
|
- **Arrays**:
|
||||||
|
- `Contains(val)` (operator `@>`)
|
||||||
|
- `ContainedBy(val)` (operator `<@`)
|
||||||
|
- `Overlaps(val)` (operator `&&`)
|
||||||
|
- **Ranges**: `Overlaps`, `Contains`, `Adjacent`, `StrictLeft`, `StrictRight`.
|
||||||
|
- **Network**: `Contains` (`>>`), `ContainedBy` (`<<`).
|
||||||
|
- **Full Text**: `Matches` (`@@`) for `TSVector` and `TSQuery`.
|
||||||
|
- **Geometry**: `DistanceTo` (`<->`), `ContainsPoint`, `WithinBox`.
|
||||||
|
- **Advanced**: Subqueries, Joins, Grouping, Having.
|
||||||
|
|
||||||
|
## 4. Updating Records
|
||||||
|
- **Standard**: `Update()`, `Updates()`.
|
||||||
|
- **JSON Updates**:
|
||||||
|
- Uses `JSONSet` expression for `JSONB_SET` operations.
|
||||||
|
- Example: `UpdateColumn("attr", types.JSONSet("attr").Set("{age}", 20))` updates a specific path inside a JSONB column without overwriting the whole document.
|
||||||
|
- **Modifiers**: `Select`, `Omit`.
|
||||||
|
|
||||||
|
## 5. Deleting Records
|
||||||
|
- **Safety**: Requires `Where` clause for bulk deletes.
|
||||||
|
- **Soft Delete**: Automatically handled if `gorm.DeletedAt` is present.
|
||||||
|
- **Associations**: Can delete specific associated records.
|
||||||
|
|
||||||
|
## 6. Transaction Management
|
||||||
|
- **Automatic**: `Transaction(func() error { ... })`.
|
||||||
|
- **Manual**: `Begin()`, `Commit()`, `Rollback()`.
|
||||||
|
- **SavePoints**: `SavePoint()`, `RollbackTo()` supported.
|
||||||
|
|
||||||
|
## 7. Association Handling
|
||||||
|
- **Relationships**: BelongsTo, HasOne, HasMany, Many2Many.
|
||||||
|
- **Eager Loading**: `Preload()` with conditions and nested paths.
|
||||||
|
- **Operations**: `Append`, `Replace`, `Delete`, `Clear` on associations.
|
||||||
|
|
||||||
|
## 8. PostgreSQL Specialized Extensions (Unique to this version)
|
||||||
|
|
||||||
|
This version of Gen is heavily customized for PostgreSQL, providing rich type support and SQL expressions that standard GORM Gen does not offer out-of-the-box.
|
||||||
|
|
||||||
|
### 8.1. Extended Type System (`go.ipao.vip/gen/types`)
|
||||||
|
Automatically maps PostgreSQL column types to specialized Go types:
|
||||||
|
|
||||||
|
- **JSON/JSONB**: `types.JSON`, `types.JSONB` (wraps `json.RawMessage`, supports GIN operators).
|
||||||
|
- **Arrays**: `types.Array[T]` (Generic implementation for `text[]`, `int[]`, etc.).
|
||||||
|
- **Ranges**:
|
||||||
|
- `types.Int4Range`, `types.Int8Range`, `types.NumRange`
|
||||||
|
- `types.TsRange` (Timestamp), `types.TstzRange` (TimestampTz), `types.DateRange`
|
||||||
|
- **Network**: `types.Inet`, `types.CIDR`, `types.MACAddr`.
|
||||||
|
- **Time**: `types.Date`, `types.Time` (Postgres specific time/date types).
|
||||||
|
- **Geometry**: `types.Point`, `types.Box`, `types.Circle`, `types.Polygon`, `types.Path`.
|
||||||
|
- **Full Text Search**: `types.TSVector`, `types.TSQuery`.
|
||||||
|
- **Others**: `types.UUID`, `types.BinUUID`, `types.Money`, `types.XML`, `types.BitString`.
|
||||||
|
- **Generics**: `types.JSONType[T]` for strong typing of JSON column content.
|
||||||
|
|
||||||
|
### 8.2. Extended Field Expressions (`go.ipao.vip/gen/field`)
|
||||||
|
Provides type-safe builders for PostgreSQL operators:
|
||||||
|
|
||||||
|
- **JSONB Querying**:
|
||||||
|
```go
|
||||||
|
// Query: attributes -> 'role' ? 'admin'
|
||||||
|
db.Where(u.Attributes.HasKey("role"))
|
||||||
|
// Query: attributes ->> 'age' > 18
|
||||||
|
db.Where(u.Attributes.KeyGt("age", 18))
|
||||||
|
```
|
||||||
|
- **Array Operations**:
|
||||||
|
```go
|
||||||
|
// Query: tags @> '{urgent}'
|
||||||
|
db.Where(u.Tags.Contains("urgent"))
|
||||||
|
```
|
||||||
|
- **Range Overlaps**:
|
||||||
|
```go
|
||||||
|
// Query: duration && '[2023-01-01, 2023-01-02)'
|
||||||
|
db.Where(u.Duration.Overlaps(searchRange))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3. Configuration & Generation
|
||||||
|
- **YAML Config**: Supports loading configuration from a `.transform.yaml` file (handling field type overrides, ignores, and relationships).
|
||||||
|
- **Auto Mapping**: `defaultDataTypeMap` in the generator automatically selects the correct extended type (e.g., `int4range` -> `types.Int4Range`) without manual config.
|
||||||
|
- **Field Wrappers**: Automatically wraps generated fields with their specific expression builders (e.g., a `jsonb` column generates a `field.JSONB` struct instead of a generic `field.Field`, enabling the `.HasKey()` method).
|
||||||
@@ -133,6 +133,8 @@
|
|||||||
- 用 main 资源绑定 preview 被拒绝;
|
- 用 main 资源绑定 preview 被拒绝;
|
||||||
- preview 秒数只对 preview 下发生效。
|
- preview 秒数只对 preview 下发生效。
|
||||||
|
|
||||||
|
> 备注(已选定实现方式):本项目采用 **新增列 `media_assets.variant`**,并对取值做 CHECK 约束(main/preview)。
|
||||||
|
|
||||||
## Epic D:异步退款/风控预留(当前 `refunding` 未使用)
|
## Epic D:异步退款/风控预留(当前 `refunding` 未使用)
|
||||||
|
|
||||||
### D1(P2, State Machine)引入 `refunding` 并定义状态迁移
|
### D1(P2, State Machine)引入 `refunding` 并定义状态迁移
|
||||||
@@ -175,4 +177,3 @@
|
|||||||
3) C1 → C2(把资源下发安全化,再强制 preview 独立产物)
|
3) C1 → C2(把资源下发安全化,再强制 preview 独立产物)
|
||||||
4) E1(审计增强,避免后续追溯成本)
|
4) E1(审计增强,避免后续追溯成本)
|
||||||
5) D1(如确需异步退款/风控,再引入)
|
5) D1(如确需异步退款/风控,再引入)
|
||||||
|
|
||||||
|
|||||||
@@ -131,11 +131,26 @@ Authorization: Bearer {{ token }}
|
|||||||
|
|
||||||
{
|
{
|
||||||
"type": "video",
|
"type": "video",
|
||||||
|
"variant": "main",
|
||||||
"content_type": "video/mp4",
|
"content_type": "video/mp4",
|
||||||
"file_size": 12345678,
|
"file_size": 12345678,
|
||||||
"sha256": ""
|
"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)
|
### Tenant Admin - MediaAsset upload complete (uploaded -> processing)
|
||||||
@assetID = 1
|
@assetID = 1
|
||||||
POST {{ host }}/t/{{ tenantCode }}/v1/admin/media_assets/{{ assetID }}/upload_complete
|
POST {{ host }}/t/{{ tenantCode }}/v1/admin/media_assets/{{ assetID }}/upload_complete
|
||||||
|
|||||||
Reference in New Issue
Block a user