Compare commits

...

3 Commits

13 changed files with 483 additions and 1 deletions

View File

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

View File

@@ -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=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
if variant == "" {
variant = mediaAssetVariantMain
}
requiredVariant := requiredMediaAssetVariantForRole(role)
if variant != requiredVariant {
return nil, errorx.ErrPreconditionFailed.WithMsg("media asset variant mismatch")
}
// 关联规则preview 产物必须声明来源 mainmain/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,

View File

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

View File

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

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

View File

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

View File

@@ -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 '派生来源资源IDpreview 产物可指向对应 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

View File

@@ -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"
} }
} }
}, },

View File

@@ -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"
} }
} }
}, },

View File

@@ -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
View 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).

View File

@@ -133,6 +133,8 @@
- 用 main 资源绑定 preview 被拒绝; - 用 main 资源绑定 preview 被拒绝;
- preview 秒数只对 preview 下发生效。 - preview 秒数只对 preview 下发生效。
> 备注(已选定实现方式):本项目采用 **新增列 `media_assets.variant`**,并对取值做 CHECK 约束main/preview
## Epic D异步退款/风控预留(当前 `refunding` 未使用) ## Epic D异步退款/风控预留(当前 `refunding` 未使用)
### D1P2, State Machine引入 `refunding` 并定义状态迁移 ### D1P2, State Machine引入 `refunding` 并定义状态迁移
@@ -175,4 +177,3 @@
3) C1 → C2把资源下发安全化再强制 preview 独立产物) 3) C1 → C2把资源下发安全化再强制 preview 独立产物)
4) E1审计增强避免后续追溯成本 4) E1审计增强避免后续追溯成本
5) D1如确需异步退款/风控,再引入) 5) D1如确需异步退款/风控,再引入)

View File

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