Compare commits

...

5 Commits

Author SHA1 Message Date
683965ae39 feat: Refactor order snapshot handling and introduce structured snapshot types
- Added new structured snapshot types for orders and order items to improve data integrity and clarity.
- Updated the Order and OrderItem models to use the new JSONType for snapshots.
- Refactored tests to accommodate the new snapshot structure, ensuring compatibility with legacy data.
- Enhanced the OrdersSnapshot struct to support multiple snapshot types and maintain backward compatibility.
- Introduced new fields for order items and orders to capture detailed snapshot information for auditing and historical display.
2025-12-22 21:11:33 +08:00
618fe116ba update llm.txt 2025-12-22 21:11:13 +08:00
83097c8ff4 update llm.txt 2025-12-22 20:59:24 +08:00
2cc823d3a8 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.
2025-12-22 19:27:31 +08:00
d04e2ee693 update llm.txt 2025-12-22 18:56:44 +08:00
22 changed files with 735 additions and 342 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ build/*
.vscode
.idea
tmp/
.cache/
.gocache/
.gotmp/
docker-compose.yml

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{
if _, err := query.Where(tbl.ID.Eq(m.ID)).Updates(map[string]any{
"status": consts.MediaAssetStatusProcessing,
"meta": types.JSON(metaBytes),
"updated_at": now,
}).Error; err != nil {
}); 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{
if _, err := query.Where(tbl.ID.Eq(m.ID)).Updates(map[string]any{
"status": consts.MediaAssetStatusReady,
"meta": types.JSON(metaBytes),
"updated_at": now,
}).Error; err != nil {
}); 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{
if _, err := query.Where(tbl.ID.Eq(m.ID)).Updates(map[string]any{
"status": consts.MediaAssetStatusFailed,
"meta": types.JSON(metaBytes),
"updated_at": now,
}).Error; err != nil {
}); 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{
if _, err := query.Where(tbl.ID.Eq(m.ID)).Updates(map[string]any{
"status": consts.MediaAssetStatusDeleted,
"updated_at": now,
}).Error; err != nil {
}); 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)
}
}

View File

@@ -14,6 +14,7 @@ import (
"quyun/v2/app/http/tenant/dto"
"quyun/v2/app/requests"
"quyun/v2/database"
"quyun/v2/database/fields"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
@@ -28,6 +29,17 @@ import (
"go.ipao.vip/gen/types"
)
func newOrderSnapshot(kind consts.OrderType, payload any) types.JSONType[fields.OrdersSnapshot] {
b, err := json.Marshal(payload)
if err != nil || len(b) == 0 {
b = []byte("{}")
}
return types.NewJSONType(fields.OrdersSnapshot{
Kind: string(kind),
Data: b,
})
}
// AdminOrderExportCSV 租户管理员导出订单列表CSV 文本)。
func (s *order) AdminOrderExportCSV(ctx context.Context, tenantID int64, filter *dto.AdminOrderListFilter) (*dto.AdminOrderExportResponse, error) {
if tenantID <= 0 {
@@ -305,76 +317,6 @@ func (s *order) AdminBatchTopupUsers(
return out, nil
}
// PurchaseOrderSnapshot 为“内容购买订单”的下单快照(用于历史展示与争议审计)。
type PurchaseOrderSnapshot struct {
// ContentID 内容ID。
ContentID int64 `json:"content_id"`
// ContentTitle 内容标题(下单时快照,避免事后改名影响历史订单展示)。
ContentTitle string `json:"content_title"`
// ContentUserID 内容作者用户ID用于审计与后续分成扩展
ContentUserID int64 `json:"content_user_id"`
// ContentVisibility 下单时的可见性快照。
ContentVisibility consts.ContentVisibility `json:"content_visibility"`
// PreviewSeconds 下单时的试看秒数快照。
PreviewSeconds int32 `json:"preview_seconds"`
// PreviewDownloadable 下单时的试看是否可下载快照(当前固定为 false
PreviewDownloadable bool `json:"preview_downloadable"`
// Currency 币种:当前固定 CNY金额单位为分
Currency consts.Currency `json:"currency"`
// PriceAmount 基础价格(分)。
PriceAmount int64 `json:"price_amount"`
// DiscountType 折扣类型none/percent/amount
DiscountType consts.DiscountType `json:"discount_type"`
// DiscountValue 折扣值percent=0-100amount=分)。
DiscountValue int64 `json:"discount_value"`
// DiscountStartAt 折扣开始时间(可选)。
DiscountStartAt *time.Time `json:"discount_start_at,omitempty"`
// DiscountEndAt 折扣结束时间(可选)。
DiscountEndAt *time.Time `json:"discount_end_at,omitempty"`
// AmountOriginal 原价金额(分)。
AmountOriginal int64 `json:"amount_original"`
// AmountDiscount 优惠金额(分)。
AmountDiscount int64 `json:"amount_discount"`
// AmountPaid 实付金额(分)。
AmountPaid int64 `json:"amount_paid"`
// PurchaseAt 下单时间(逻辑时间)。
PurchaseAt time.Time `json:"purchase_at"`
// PurchaseIdempotency 幂等键(可选)。
PurchaseIdempotency string `json:"purchase_idempotency_key,omitempty"`
// PurchasePricingNotes 价格计算补充说明(可选,便于排查争议)。
PurchasePricingNotes string `json:"purchase_pricing_notes,omitempty"`
}
// OrderItemSnapshot 为“订单明细”的内容快照。
type OrderItemSnapshot struct {
// ContentID 内容ID。
ContentID int64 `json:"content_id"`
// ContentTitle 内容标题快照。
ContentTitle string `json:"content_title"`
// ContentUserID 内容作者用户ID。
ContentUserID int64 `json:"content_user_id"`
// AmountPaid 该行实付金额(分)。
AmountPaid int64 `json:"amount_paid"`
}
// TopupOrderSnapshot 为“后台充值订单”的快照(用于审计与追责)。
type TopupOrderSnapshot struct {
// OperatorUserID 充值操作人用户ID租户管理员
OperatorUserID int64 `json:"operator_user_id"`
// TargetUserID 充值目标用户ID租户成员
TargetUserID int64 `json:"target_user_id"`
// Amount 充值金额(分)。
Amount int64 `json:"amount"`
// Currency 币种:当前固定 CNY金额单位为分
Currency consts.Currency `json:"currency"`
// Reason 充值原因(可选,强烈建议填写用于审计)。
Reason string `json:"reason,omitempty"`
// IdempotencyKey 幂等键(可选)。
IdempotencyKey string `json:"idempotency_key,omitempty"`
// TopupAt 充值时间(逻辑时间)。
TopupAt time.Time `json:"topup_at"`
}
// PurchaseContentParams 定义“租户内使用余额购买内容”的入参。
type PurchaseContentParams struct {
// TenantID 租户 ID多租户隔离范围
@@ -409,17 +351,6 @@ type order struct {
ledger *ledger
}
func marshalSnapshot(v any) types.JSON {
b, err := json.Marshal(v)
if err != nil {
return types.JSON([]byte("{}"))
}
if len(b) == 0 {
return types.JSON([]byte("{}"))
}
return types.JSON(b)
}
// AdminTopupUser 租户管理员给租户成员充值(增加该租户下的可用余额)。
func (s *order) AdminTopupUser(
ctx context.Context,
@@ -475,7 +406,7 @@ func (s *order) AdminTopupUser(
}
// 先落订单paid再写入账本credit_topup确保“订单可追溯 + 账本可对账”。
snapshot := marshalSnapshot(&TopupOrderSnapshot{
snapshot := newOrderSnapshot(consts.OrderTypeTopup, &fields.OrdersTopupSnapshot{
OperatorUserID: operatorUserID,
TargetUserID: targetUserID,
Amount: amount,
@@ -1061,7 +992,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara
discountEndAt = &t
}
purchaseSnapshot := marshalSnapshot(&PurchaseOrderSnapshot{
purchaseSnapshot := newOrderSnapshot(consts.OrderTypeContentPurchase, &fields.OrdersContentPurchaseSnapshot{
ContentID: content.ID,
ContentTitle: content.Title,
ContentUserID: content.UserID,
@@ -1080,7 +1011,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara
PurchaseAt: now,
PurchaseIdempotency: params.IdempotencyKey,
})
itemSnapshot := marshalSnapshot(&OrderItemSnapshot{
itemSnapshot := types.NewJSONType(fields.OrderItemsSnapshot{
ContentID: content.ID,
ContentTitle: content.Title,
ContentUserID: content.UserID,
@@ -1303,7 +1234,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara
t := price.DiscountEndAt
discountEndAt = &t
}
purchaseSnapshot := marshalSnapshot(&PurchaseOrderSnapshot{
purchaseSnapshot := newOrderSnapshot(consts.OrderTypeContentPurchase, &fields.OrdersContentPurchaseSnapshot{
ContentID: content.ID,
ContentTitle: content.Title,
ContentUserID: content.UserID,
@@ -1321,7 +1252,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara
AmountPaid: amountPaid,
PurchaseAt: now,
})
itemSnapshot := marshalSnapshot(&OrderItemSnapshot{
itemSnapshot := types.NewJSONType(fields.OrderItemsSnapshot{
ContentID: content.ID,
ContentTitle: content.Title,
ContentUserID: content.UserID,

View File

@@ -14,6 +14,7 @@ import (
"quyun/v2/app/http/tenant/dto"
"quyun/v2/app/requests"
"quyun/v2/database"
"quyun/v2/database/fields"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
@@ -27,6 +28,13 @@ import (
"go.uber.org/dig"
)
func newLegacyOrderSnapshot() types.JSONType[fields.OrdersSnapshot] {
return types.NewJSONType(fields.OrdersSnapshot{
Kind: "legacy",
Data: json.RawMessage([]byte("{}")),
})
}
type OrderTestSuiteInjectParams struct {
dig.In
@@ -144,11 +152,14 @@ func (s *OrderTestSuite) Test_AdminTopupUser() {
So(orderModel.Status, ShouldEqual, consts.OrderStatusPaid)
So(orderModel.AmountPaid, ShouldEqual, 300)
var snap map[string]any
So(json.Unmarshal([]byte(orderModel.Snapshot), &snap), ShouldBeNil)
So(snap["operator_user_id"], ShouldEqual, float64(operatorUserID))
So(snap["target_user_id"], ShouldEqual, float64(targetUserID))
So(snap["amount"], ShouldEqual, float64(300))
snap := orderModel.Snapshot.Data()
So(snap.Kind, ShouldEqual, string(consts.OrderTypeTopup))
var snapData fields.OrdersTopupSnapshot
So(json.Unmarshal(snap.Data, &snapData), ShouldBeNil)
So(snapData.OperatorUserID, ShouldEqual, operatorUserID)
So(snapData.TargetUserID, ShouldEqual, targetUserID)
So(snapData.Amount, ShouldEqual, int64(300))
var tu models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, targetUserID).First(&tu).Error, ShouldBeNil)
@@ -211,7 +222,7 @@ func (s *OrderTestSuite) Test_MyOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -224,7 +235,7 @@ func (s *OrderTestSuite) Test_MyOrderPage() {
ContentID: 111,
ContentUserID: 1,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}),
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
@@ -236,7 +247,7 @@ func (s *OrderTestSuite) Test_MyOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 200,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now.Add(time.Minute),
CreatedAt: now.Add(time.Minute),
UpdatedAt: now.Add(time.Minute),
@@ -249,7 +260,7 @@ func (s *OrderTestSuite) Test_MyOrderPage() {
ContentID: 222,
ContentUserID: 1,
AmountPaid: 200,
Snapshot: types.JSON([]byte("{}")),
Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}),
CreatedAt: now.Add(time.Minute),
UpdatedAt: now.Add(time.Minute),
}).Create(ctx), ShouldBeNil)
@@ -310,7 +321,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now.Add(-time.Hour),
CreatedAt: now.Add(-time.Hour),
UpdatedAt: now.Add(-time.Hour),
@@ -323,7 +334,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
ContentID: 333,
ContentUserID: 1,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}),
CreatedAt: now.Add(-time.Hour),
UpdatedAt: now.Add(-time.Hour),
}).Create(ctx), ShouldBeNil)
@@ -335,7 +346,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 200,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -348,7 +359,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
ContentID: 444,
ContentUserID: 1,
AmountPaid: 200,
Snapshot: types.JSON([]byte("{}")),
Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}),
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
@@ -373,7 +384,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -386,7 +397,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
ContentID: 555,
ContentUserID: 1,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}),
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
@@ -429,7 +440,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -442,7 +453,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -499,7 +510,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -512,7 +523,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
ContentID: c1.ID,
ContentUserID: 1,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}),
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
@@ -524,7 +535,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -537,7 +548,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
ContentID: c2.ID,
ContentUserID: 1,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}),
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
@@ -560,7 +571,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now.Add(-time.Hour),
UpdatedAt: now.Add(-time.Hour),
@@ -574,7 +585,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 200,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -601,7 +612,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 500,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now.Add(-time.Hour),
UpdatedAt: now.Add(-time.Hour),
@@ -615,7 +626,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -653,7 +664,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -667,7 +678,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 200,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -697,7 +708,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 500,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -710,7 +721,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
ContentID: contentID,
ContentUserID: 1,
AmountPaid: 500,
Snapshot: types.JSON([]byte("{}")),
Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}),
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
@@ -723,7 +734,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 50,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -736,7 +747,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
ContentID: contentID,
ContentUserID: 1,
AmountPaid: 50,
Snapshot: types.JSON([]byte("{}")),
Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}),
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
@@ -749,7 +760,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusCreated,
Currency: consts.CurrencyCNY,
AmountPaid: 500,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -762,7 +773,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
ContentID: contentID,
ContentUserID: 1,
AmountPaid: 500,
Snapshot: types.JSON([]byte("{}")),
Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}),
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
@@ -775,7 +786,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 500,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -788,7 +799,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
ContentID: contentID,
ContentUserID: 1,
AmountPaid: 500,
Snapshot: types.JSON([]byte("{}")),
Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}),
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
@@ -859,7 +870,7 @@ func (s *OrderTestSuite) Test_AdminOrderExportCSV() {
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 123,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -1028,7 +1039,7 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() {
AmountOriginal: 100,
AmountDiscount: 0,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -1055,7 +1066,7 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() {
AmountOriginal: 100,
AmountDiscount: 0,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now.Add(-consts.DefaultOrderRefundWindow).Add(-time.Second),
CreatedAt: now,
UpdatedAt: now,
@@ -1079,7 +1090,7 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() {
AmountOriginal: 300,
AmountDiscount: 0,
AmountPaid: 300,
Snapshot: types.JSON([]byte("{}")),
Snapshot: newLegacyOrderSnapshot(),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -1093,7 +1104,7 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() {
ContentID: contentID,
ContentUserID: 999,
AmountPaid: 300,
Snapshot: types.JSON([]byte("{}")),
Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}),
CreatedAt: now,
UpdatedAt: now,
}
@@ -1217,11 +1228,14 @@ func (s *OrderTestSuite) Test_PurchaseContent() {
So(res1.Access, ShouldNotBeNil)
So(res1.Access.Status, ShouldEqual, consts.ContentAccessStatusActive)
var snap map[string]any
So(json.Unmarshal([]byte(res1.Order.Snapshot), &snap), ShouldBeNil)
So(snap["content_id"], ShouldEqual, float64(content.ID))
So(snap["content_title"], ShouldEqual, content.Title)
So(snap["amount_paid"], ShouldEqual, float64(0))
snap := res1.Order.Snapshot.Data()
So(snap.Kind, ShouldEqual, string(consts.OrderTypeContentPurchase))
var snapData fields.OrdersContentPurchaseSnapshot
So(json.Unmarshal(snap.Data, &snapData), ShouldBeNil)
So(snapData.ContentID, ShouldEqual, content.ID)
So(snapData.ContentTitle, ShouldEqual, content.Title)
So(snapData.AmountPaid, ShouldEqual, int64(0))
res2, err := Order.PurchaseContent(ctx, &PurchaseContentParams{
TenantID: tenantID,
@@ -1264,16 +1278,18 @@ func (s *OrderTestSuite) Test_PurchaseContent() {
So(res1.Access, ShouldNotBeNil)
So(res1.Access.Status, ShouldEqual, consts.ContentAccessStatusActive)
var snap map[string]any
So(json.Unmarshal([]byte(res1.Order.Snapshot), &snap), ShouldBeNil)
So(snap["content_id"], ShouldEqual, float64(content.ID))
So(snap["amount_paid"], ShouldEqual, float64(300))
So(snap["amount_original"], ShouldEqual, float64(300))
snap := res1.Order.Snapshot.Data()
So(snap.Kind, ShouldEqual, string(consts.OrderTypeContentPurchase))
var itemSnap map[string]any
So(json.Unmarshal([]byte(res1.OrderItem.Snapshot), &itemSnap), ShouldBeNil)
So(itemSnap["content_id"], ShouldEqual, float64(content.ID))
So(itemSnap["amount_paid"], ShouldEqual, float64(300))
var snapData fields.OrdersContentPurchaseSnapshot
So(json.Unmarshal(snap.Data, &snapData), ShouldBeNil)
So(snapData.ContentID, ShouldEqual, content.ID)
So(snapData.AmountPaid, ShouldEqual, int64(300))
So(snapData.AmountOriginal, ShouldEqual, int64(300))
itemSnap := res1.OrderItem.Snapshot.Data()
So(itemSnap.ContentID, ShouldEqual, content.ID)
So(itemSnap.AmountPaid, ShouldEqual, int64(300))
var tu models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu).Error, ShouldBeNil)

View File

@@ -9,6 +9,7 @@ ignores:
imports:
- go.ipao.vip/gen
- quyun/v2/pkg/consts
- quyun/v2/database/fields
field_type:
users:
roles: types.Array[consts.Role]
@@ -22,6 +23,7 @@ field_type:
media_assets:
type: consts.MediaAssetType
status: consts.MediaAssetStatus
variant: consts.MediaAssetVariant
contents:
status: consts.ContentStatus
visibility: consts.ContentVisibility
@@ -36,6 +38,9 @@ field_type:
type: consts.OrderType
status: consts.OrderStatus
currency: consts.Currency
snapshot: types.JSONType[fields.OrdersSnapshot]
order_items:
snapshot: types.JSONType[fields.OrderItemsSnapshot]
tenant_ledgers:
type: consts.TenantLedgerType
tenant_invites:

View File

@@ -0,0 +1,15 @@
package fields
// OrderItemsSnapshot 定义 order_items.snapshot 的固定结构。
//
// 该快照用于“历史展示与审计”:即使内容标题/作者等信息后续变更,也不影响已下单记录的展示一致性。
type OrderItemsSnapshot struct {
// ContentID 内容 ID。
ContentID int64 `json:"content_id"`
// ContentTitle 内容标题快照。
ContentTitle string `json:"content_title"`
// ContentUserID 内容作者用户 ID。
ContentUserID int64 `json:"content_user_id"`
// AmountPaid 该行实付金额(分)。
AmountPaid int64 `json:"amount_paid"`
}

View File

@@ -0,0 +1,98 @@
package fields
import (
"encoding/json"
"time"
"quyun/v2/pkg/consts"
)
// OrdersSnapshot 是 orders.snapshot 的统一包裹结构(判别联合)。
//
// 设计目标:
// - 同一字段支持多种快照结构(按 kind 区分)。
// - 查询/展示时可以先看 kind再按需解析 data。
// - 兼容历史数据:如果旧数据没有 kind/data则按 legacy 处理data = 原始 JSON
type OrdersSnapshot struct {
// Kind 快照类型:建议与订单类型对齐(例如 content_purchase / topup
Kind string `json:"kind"`
// Data 具体快照数据(按 Kind 对应不同结构)。
Data json.RawMessage `json:"data"`
}
type ordersSnapshotWire struct {
Kind string `json:"kind"`
Data json.RawMessage `json:"data"`
}
func (s *OrdersSnapshot) UnmarshalJSON(b []byte) error {
var w ordersSnapshotWire
if err := json.Unmarshal(b, &w); err == nil && (w.Kind != "" || w.Data != nil) {
s.Kind = w.Kind
s.Data = w.Data
return nil
}
// 兼容旧结构:旧 snapshot 通常是一个扁平对象(没有 kind/data
s.Kind = "legacy"
s.Data = append(s.Data[:0], b...)
return nil
}
// OrdersContentPurchaseSnapshot 为“内容购买订单”的下单快照(用于历史展示与争议审计)。
type OrdersContentPurchaseSnapshot struct {
// ContentID 内容ID。
ContentID int64 `json:"content_id"`
// ContentTitle 内容标题(下单时快照,避免事后改名影响历史订单展示)。
ContentTitle string `json:"content_title"`
// ContentUserID 内容作者用户ID用于审计与后续分成扩展
ContentUserID int64 `json:"content_user_id"`
// ContentVisibility 下单时的可见性快照。
ContentVisibility consts.ContentVisibility `json:"content_visibility"`
// PreviewSeconds 下单时的试看秒数快照。
PreviewSeconds int32 `json:"preview_seconds"`
// PreviewDownloadable 下单时的试看是否可下载快照(当前固定为 false
PreviewDownloadable bool `json:"preview_downloadable"`
// Currency 币种:当前固定 CNY金额单位为分
Currency consts.Currency `json:"currency"`
// PriceAmount 基础价格(分)。
PriceAmount int64 `json:"price_amount"`
// DiscountType 折扣类型none/percent/amount
DiscountType consts.DiscountType `json:"discount_type"`
// DiscountValue 折扣值percent=0-100amount=分)。
DiscountValue int64 `json:"discount_value"`
// DiscountStartAt 折扣开始时间(可选)。
DiscountStartAt *time.Time `json:"discount_start_at,omitempty"`
// DiscountEndAt 折扣结束时间(可选)。
DiscountEndAt *time.Time `json:"discount_end_at,omitempty"`
// AmountOriginal 原价金额(分)。
AmountOriginal int64 `json:"amount_original"`
// AmountDiscount 优惠金额(分)。
AmountDiscount int64 `json:"amount_discount"`
// AmountPaid 实付金额(分)。
AmountPaid int64 `json:"amount_paid"`
// PurchaseAt 下单时间(逻辑时间)。
PurchaseAt time.Time `json:"purchase_at"`
// PurchaseIdempotency 幂等键(可选)。
PurchaseIdempotency string `json:"purchase_idempotency_key,omitempty"`
// PurchasePricingNotes 价格计算补充说明(可选,便于排查争议)。
PurchasePricingNotes string `json:"purchase_pricing_notes,omitempty"`
}
// OrdersTopupSnapshot 为“后台充值订单”的快照(用于审计与追责)。
type OrdersTopupSnapshot struct {
// OperatorUserID 充值操作人用户ID租户管理员
OperatorUserID int64 `json:"operator_user_id"`
// TargetUserID 充值目标用户ID租户成员
TargetUserID int64 `json:"target_user_id"`
// Amount 充值金额(分)。
Amount int64 `json:"amount"`
// Currency 币种:当前固定 CNY金额单位为分
Currency consts.Currency `json:"currency"`
// Reason 充值原因(可选,强烈建议填写用于审计)。
Reason string `json:"reason,omitempty"`
// IdempotencyKey 幂等键(可选)。
IdempotencyKey string `json:"idempotency_key,omitempty"`
// TopupAt 充值时间(逻辑时间)。
TopupAt time.Time `json:"topup_at"`
}

View File

@@ -9,9 +9,19 @@ SET variant = 'main'
WHERE variant IS NULL OR variant = '';
-- 约束:只允许 main/preview
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'ck_media_assets_variant'
) THEN
ALTER TABLE media_assets
ADD CONSTRAINT IF NOT EXISTS ck_media_assets_variant
ADD CONSTRAINT ck_media_assets_variant
CHECK (variant IN ('main', 'preview'));
END IF;
END
$$;
COMMENT ON COLUMN media_assets.variant IS '产物类型main/preview用于强制试看资源必须绑定独立产物避免用正片绕过';
@@ -24,4 +34,3 @@ 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

@@ -31,6 +31,8 @@ type MediaAsset struct {
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone;comment:软删除时间:非空表示已删除;对外接口需过滤" json:"deleted_at"` // 软删除时间:非空表示已删除;对外接口需过滤
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now();用于审计与排序" json:"created_at"` // 创建时间:默认 now();用于审计与排序
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now();更新状态/元数据时写入" json:"updated_at"` // 更新时间:默认 now();更新状态/元数据时写入
Variant consts.MediaAssetVariant `gorm:"column:variant;type:character varying(32);not null;default:main;comment:产物类型main/preview用于强制试看资源必须绑定独立产物避免用正片绕过" json:"variant"` // 产物类型main/preview用于强制试看资源必须绑定独立产物避免用正片绕过
SourceAssetID int64 `gorm:"column:source_asset_id;type:bigint;comment:派生来源资源IDpreview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系" json:"source_asset_id"` // 派生来源资源IDpreview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系
}
// Quick operations without importing query package

View File

@@ -37,6 +37,8 @@ func newMediaAsset(db *gorm.DB, opts ...gen.DOOption) mediaAssetQuery {
_mediaAssetQuery.DeletedAt = field.NewField(tableName, "deleted_at")
_mediaAssetQuery.CreatedAt = field.NewTime(tableName, "created_at")
_mediaAssetQuery.UpdatedAt = field.NewTime(tableName, "updated_at")
_mediaAssetQuery.Variant = field.NewField(tableName, "variant")
_mediaAssetQuery.SourceAssetID = field.NewInt64(tableName, "source_asset_id")
_mediaAssetQuery.fillFieldMap()
@@ -59,6 +61,8 @@ type mediaAssetQuery struct {
DeletedAt field.Field // 软删除时间:非空表示已删除;对外接口需过滤
CreatedAt field.Time // 创建时间:默认 now();用于审计与排序
UpdatedAt field.Time // 更新时间:默认 now();更新状态/元数据时写入
Variant field.Field // 产物类型main/preview用于强制试看资源必须绑定独立产物避免用正片绕过
SourceAssetID field.Int64 // 派生来源资源IDpreview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系
fieldMap map[string]field.Expr
}
@@ -87,6 +91,8 @@ func (m *mediaAssetQuery) updateTableName(table string) *mediaAssetQuery {
m.DeletedAt = field.NewField(table, "deleted_at")
m.CreatedAt = field.NewTime(table, "created_at")
m.UpdatedAt = field.NewTime(table, "updated_at")
m.Variant = field.NewField(table, "variant")
m.SourceAssetID = field.NewInt64(table, "source_asset_id")
m.fillFieldMap()
@@ -119,7 +125,7 @@ func (m *mediaAssetQuery) GetFieldByName(fieldName string) (field.OrderExpr, boo
}
func (m *mediaAssetQuery) fillFieldMap() {
m.fieldMap = make(map[string]field.Expr, 12)
m.fieldMap = make(map[string]field.Expr, 14)
m.fieldMap["id"] = m.ID
m.fieldMap["tenant_id"] = m.TenantID
m.fieldMap["user_id"] = m.UserID
@@ -132,6 +138,8 @@ func (m *mediaAssetQuery) fillFieldMap() {
m.fieldMap["deleted_at"] = m.DeletedAt
m.fieldMap["created_at"] = m.CreatedAt
m.fieldMap["updated_at"] = m.UpdatedAt
m.fieldMap["variant"] = m.Variant
m.fieldMap["source_asset_id"] = m.SourceAssetID
}
func (m mediaAssetQuery) clone(db *gorm.DB) mediaAssetQuery {

View File

@@ -8,6 +8,8 @@ import (
"context"
"time"
"quyun/v2/database/fields"
"go.ipao.vip/gen"
"go.ipao.vip/gen/types"
)
@@ -23,7 +25,7 @@ type OrderItem struct {
ContentID int64 `gorm:"column:content_id;type:bigint;not null;comment:内容ID关联 contents.id用于生成/撤销 content_access" json:"content_id"` // 内容ID关联 contents.id用于生成/撤销 content_access
ContentUserID int64 `gorm:"column:content_user_id;type:bigint;not null;comment:内容作者用户ID用于后续分成/对账扩展;当前可为 0 或写入内容创建者" json:"content_user_id"` // 内容作者用户ID用于后续分成/对账扩展;当前可为 0 或写入内容创建者
AmountPaid int64 `gorm:"column:amount_paid;type:bigint;not null;comment:该行实付金额:分;通常等于订单 amount_paid单内容场景" json:"amount_paid"` // 该行实付金额:分;通常等于订单 amount_paid单内容场景
Snapshot types.JSON `gorm:"column:snapshot;type:jsonb;not null;default:{};comment:内容快照JSON建议包含 title/price/discount 等,用于历史展示与审计" json:"snapshot"` // 内容快照JSON建议包含 title/price/discount 等,用于历史展示与审计
Snapshot types.JSONType[fields.OrderItemsSnapshot] `gorm:"column:snapshot;type:jsonb;not null;default:{};comment:内容快照JSON建议包含 title/price/discount 等,用于历史展示与审计" json:"snapshot"` // 内容快照JSON建议包含 title/price/discount 等,用于历史展示与审计
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now()
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now()
Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,omitempty"`

View File

@@ -8,6 +8,7 @@ import (
"context"
"time"
"quyun/v2/database/fields"
"quyun/v2/pkg/consts"
"go.ipao.vip/gen"
@@ -27,7 +28,7 @@ type Order struct {
AmountOriginal int64 `gorm:"column:amount_original;type:bigint;not null;comment:原价金额:分;未折扣前金额(用于展示与对账)" json:"amount_original"` // 原价金额:分;未折扣前金额(用于展示与对账)
AmountDiscount int64 `gorm:"column:amount_discount;type:bigint;not null;comment:优惠金额amount_paid = amount_original - amount_discount下单时快照" json:"amount_discount"` // 优惠金额amount_paid = amount_original - amount_discount下单时快照
AmountPaid int64 `gorm:"column:amount_paid;type:bigint;not null;comment:实付金额:分;从租户内余额扣款的金额(下单时快照)" json:"amount_paid"` // 实付金额:分;从租户内余额扣款的金额(下单时快照)
Snapshot types.JSON `gorm:"column:snapshot;type:jsonb;not null;default:{};comment:订单快照JSON建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示" json:"snapshot"` // 订单快照JSON建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示
Snapshot types.JSONType[fields.OrdersSnapshot] `gorm:"column:snapshot;type:jsonb;not null;default:{};comment:订单快照JSON建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示" json:"snapshot"` // 订单快照JSON建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示
IdempotencyKey string `gorm:"column:idempotency_key;type:character varying(128);not null;comment:幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成)" json:"idempotency_key"` // 幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成)
PaidAt time.Time `gorm:"column:paid_at;type:timestamp with time zone;comment:支付/扣款完成时间:余额支付在 debit_purchase 成功后写入" json:"paid_at"` // 支付/扣款完成时间:余额支付在 debit_purchase 成功后写入
RefundedAt time.Time `gorm:"column:refunded_at;type:timestamp with time zone;comment:退款完成时间:退款落账成功后写入" json:"refunded_at"` // 退款完成时间:退款落账成功后写入

View File

@@ -2793,6 +2793,17 @@ const docTemplate = `{
"MediaAssetTypeImage"
]
},
"consts.MediaAssetVariant": {
"type": "string",
"enum": [
"main",
"preview"
],
"x-enum-varnames": [
"MediaAssetVariantMain",
"MediaAssetVariantPreview"
]
},
"consts.OrderStatus": {
"type": "string",
"enum": [
@@ -3056,7 +3067,11 @@ const docTemplate = `{
},
"variant": {
"description": "Variant indicates whether this asset is a main or preview product.\nAllowed: main/preview; default is main.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/consts.MediaAssetVariant"
}
]
}
}
},
@@ -4061,6 +4076,10 @@ const docTemplate = `{
"description": "存储提供方:例如 s3/minio/oss便于多存储扩展",
"type": "string"
},
"source_asset_id": {
"description": "派生来源资源IDpreview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系",
"type": "integer"
},
"status": {
"description": "处理状态uploaded/processing/ready/failed/deletedready 才可被内容引用对外提供",
"allOf": [
@@ -4088,6 +4107,14 @@ const docTemplate = `{
"user_id": {
"description": "用户ID资源上传者用于审计与权限控制",
"type": "integer"
},
"variant": {
"description": "产物类型main/preview用于强制试看资源必须绑定独立产物避免用正片绕过",
"allOf": [
{
"$ref": "#/definitions/consts.MediaAssetVariant"
}
]
}
}
},

View File

@@ -2787,6 +2787,17 @@
"MediaAssetTypeImage"
]
},
"consts.MediaAssetVariant": {
"type": "string",
"enum": [
"main",
"preview"
],
"x-enum-varnames": [
"MediaAssetVariantMain",
"MediaAssetVariantPreview"
]
},
"consts.OrderStatus": {
"type": "string",
"enum": [
@@ -3050,7 +3061,11 @@
},
"variant": {
"description": "Variant indicates whether this asset is a main or preview product.\nAllowed: main/preview; default is main.",
"type": "string"
"allOf": [
{
"$ref": "#/definitions/consts.MediaAssetVariant"
}
]
}
}
},
@@ -4055,6 +4070,10 @@
"description": "存储提供方:例如 s3/minio/oss便于多存储扩展",
"type": "string"
},
"source_asset_id": {
"description": "派生来源资源IDpreview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系",
"type": "integer"
},
"status": {
"description": "处理状态uploaded/processing/ready/failed/deletedready 才可被内容引用对外提供",
"allOf": [
@@ -4082,6 +4101,14 @@
"user_id": {
"description": "用户ID资源上传者用于审计与权限控制",
"type": "integer"
},
"variant": {
"description": "产物类型main/preview用于强制试看资源必须绑定独立产物避免用正片绕过",
"allOf": [
{
"$ref": "#/definitions/consts.MediaAssetVariant"
}
]
}
}
},

View File

@@ -84,6 +84,14 @@ definitions:
- MediaAssetTypeVideo
- MediaAssetTypeAudio
- MediaAssetTypeImage
consts.MediaAssetVariant:
enum:
- main
- preview
type: string
x-enum-varnames:
- MediaAssetVariantMain
- MediaAssetVariantPreview
consts.OrderStatus:
enum:
- created
@@ -299,10 +307,11 @@ definitions:
Used to decide processing pipeline and validation rules; required.
type: string
variant:
allOf:
- $ref: '#/definitions/consts.MediaAssetVariant'
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:
@@ -976,6 +985,9 @@ definitions:
provider:
description: 存储提供方:例如 s3/minio/oss便于多存储扩展
type: string
source_asset_id:
description: 派生来源资源IDpreview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系
type: integer
status:
allOf:
- $ref: '#/definitions/consts.MediaAssetStatus'
@@ -993,6 +1005,10 @@ definitions:
user_id:
description: 用户ID资源上传者用于审计与权限控制
type: integer
variant:
allOf:
- $ref: '#/definitions/consts.MediaAssetVariant'
description: 产物类型main/preview用于强制试看资源必须绑定独立产物避免用正片绕过
type: object
models.Order:
properties:

View File

@@ -10,6 +10,7 @@ This file condenses `backend/docs/dev/http_api.md` + `backend/docs/dev/model.md`
- MUST: HTTP module folder name MUST be `snake_case` (e.g. `tenant_public`), not `camelCase`/`mixedCase`.
- DO keep controller methods thin: parse/bind → call `services.*` → return result/error.
- DO regenerate code after changes (routes/docs/models).
- MUST: in `backend/app/services`, prefer the generated GORM-Gen DAO (`backend/database/models/*`) for DB access; treat raw `*gorm.DB` usage as a last resort.
- MUST: after adding/removing/renaming any files under `backend/app/services/`, run `atomctl gen service --path ./app/services` to regenerate `backend/app/services/services.gen.go`; DO NOT edit `services.gen.go` manually.
- MUST: a single service's methods MUST live in a single file; do NOT split one service across multiple files (e.g. `type user struct{}` in `user.go` but methods in `user_admin.go`), because `atomctl gen service` uses filenames to infer services and will generate incorrect `services.gen.go`.
- DO add `// @provider` above every controller/service `struct` declaration.
@@ -155,6 +156,42 @@ Models live in:
- `atomctl gen model`
---
## 3) Service-layer DB access (GORM Gen)
This project uses a PostgreSQL-focused GORM-Gen variant (`go.ipao.vip/gen` + generated `backend/database/models/*`).
Reference: `backend/llm.gorm_gen.txt`.
### 3.1 Query style (preferred)
- MUST: in services, build queries via:
- `tbl, q := models.<Table>Query.QueryContext(ctx)`
- Use type-safe conditions (`tbl.ID.Eq(...)`, `tbl.TenantID.Eq(...)`, `tbl.DeletedAt.IsNull()`, etc).
- DO NOT: use string SQL in `Where("...")` unless absolutely necessary.
### 3.2 Transactions
- MUST: use Gen transaction wrapper so all queries share the same tx connection:
- `models.Q.Transaction(func(tx *models.Query) error { ... })`
- Inside tx, use `tx.<Table>.QueryContext(ctx)` / `tx.<Table>.WithContext(ctx)`
- DO NOT: use `_db.WithContext(ctx).Transaction(...)` in services unless Gen cannot express a required operation.
### 3.3 Updates
- Prefer `UpdateSimple(...)` with typed assign expressions when possible.
- Otherwise use `Updates(map[string]any{...})`, but MUST:
- include tenant boundary conditions (`tenant_id`) in the WHERE,
- avoid updating columns by concatenating user input.
### 3.4 Columns not in generated models (temporary escape hatch)
If migrations add columns but `atomctl gen model` has not been re-run yet, the typed `models.<Struct>` will not contain those fields.
In this case:
- Use `q.UnderlyingDB()` (from Gen DO) to do a narrow query/update (single table, explicit columns).
- Add a short Chinese comment explaining why, and that `atomctl gen model` should be run when DB is reachable.
- Avoid spreading this pattern: keep it localized to one function.
### 2.2 Enum strategy
- DO NOT use native DB ENUM.
@@ -181,6 +218,26 @@ type UserStatus string
Common types:
- JSON: `types.JSON`, `types.JSONMap`, `types.JSONType[T]`, `types.JSONSlice[T]`
### 2.4 JSONB 强类型规则(`types.JSONType[T]`
- 如果某个 `jsonb` 字段的数据结构是“确定且稳定”的,优先将 `types.JSON` 升级为 `types.JSONType[fields.TableNameFieldName]`,以获得类型约束与更清晰的读写代码。
- `fields.TableNameFieldName` 必须定义在 `backend/database/fields/[table_name].go` 中,格式为 `type TableNameFieldName struct { ... }` 并为每个字段写好 `json` tag。
- 如果数据结构“不确定/随业务演进/允许任意键”,继续使用 `types.JSON`(不要强行 JSONType以免丢字段或引入频繁迁移
- 服务层读写 `types.JSONType[T]`
- 读取:`v := model.Field.Data()`
- 修改:`model.Field.Edit(func(v *T) { ... })` 或 `model.Field.Set(newValue)`
### 2.5 一个字段多种结构(判别联合)
- 当同一个 `jsonb` 字段存在多种不同结构(例如订单快照:充值 vs 购买),不要让字段类型漂移为 `any/map`。
- 推荐统一包裹为“判别联合”结构:`type Xxx struct { Kind string; Data json.RawMessage }`,并将该字段映射为 `types.JSONType[fields.Xxx]`。
- 写入时:
- `Kind` 建议与业务枚举对齐(例如订单类型),便于 SQL/报表按 `kind` 过滤。
- `Data` 写入对应 payload 的 JSONpayload 可以是多个不同 struct
- 读取时:
- 先 `snap := model.Snapshot.Data()`,再 `switch snap.Kind` 选择对应 payload 结构去 `json.Unmarshal(snap.Data, &payload)`。
- 兼容历史数据(旧 JSON 没有 kind/data`UnmarshalJSON` 可以将其标记为 `legacy` 并把原始 JSON 放入 `Data`,避免线上存量读取失败。
- Array: `types.Array[T]`
- UUID: `types.UUID`, `types.BinUUID`
- Date/Time: `types.Date`, `types.Time`

View File

@@ -1343,6 +1343,166 @@ func (x NullMediaAssetTypeStr) Value() (driver.Value, error) {
return x.MediaAssetType.String(), nil
}
const (
// MediaAssetVariantMain is a MediaAssetVariant of type main.
MediaAssetVariantMain MediaAssetVariant = "main"
// MediaAssetVariantPreview is a MediaAssetVariant of type preview.
MediaAssetVariantPreview MediaAssetVariant = "preview"
)
var ErrInvalidMediaAssetVariant = fmt.Errorf("not a valid MediaAssetVariant, try [%s]", strings.Join(_MediaAssetVariantNames, ", "))
var _MediaAssetVariantNames = []string{
string(MediaAssetVariantMain),
string(MediaAssetVariantPreview),
}
// MediaAssetVariantNames returns a list of possible string values of MediaAssetVariant.
func MediaAssetVariantNames() []string {
tmp := make([]string, len(_MediaAssetVariantNames))
copy(tmp, _MediaAssetVariantNames)
return tmp
}
// MediaAssetVariantValues returns a list of the values for MediaAssetVariant
func MediaAssetVariantValues() []MediaAssetVariant {
return []MediaAssetVariant{
MediaAssetVariantMain,
MediaAssetVariantPreview,
}
}
// String implements the Stringer interface.
func (x MediaAssetVariant) String() string {
return string(x)
}
// IsValid provides a quick way to determine if the typed value is
// part of the allowed enumerated values
func (x MediaAssetVariant) IsValid() bool {
_, err := ParseMediaAssetVariant(string(x))
return err == nil
}
var _MediaAssetVariantValue = map[string]MediaAssetVariant{
"main": MediaAssetVariantMain,
"preview": MediaAssetVariantPreview,
}
// ParseMediaAssetVariant attempts to convert a string to a MediaAssetVariant.
func ParseMediaAssetVariant(name string) (MediaAssetVariant, error) {
if x, ok := _MediaAssetVariantValue[name]; ok {
return x, nil
}
return MediaAssetVariant(""), fmt.Errorf("%s is %w", name, ErrInvalidMediaAssetVariant)
}
var errMediaAssetVariantNilPtr = errors.New("value pointer is nil") // one per type for package clashes
// Scan implements the Scanner interface.
func (x *MediaAssetVariant) Scan(value interface{}) (err error) {
if value == nil {
*x = MediaAssetVariant("")
return
}
// A wider range of scannable types.
// driver.Value values at the top of the list for expediency
switch v := value.(type) {
case string:
*x, err = ParseMediaAssetVariant(v)
case []byte:
*x, err = ParseMediaAssetVariant(string(v))
case MediaAssetVariant:
*x = v
case *MediaAssetVariant:
if v == nil {
return errMediaAssetVariantNilPtr
}
*x = *v
case *string:
if v == nil {
return errMediaAssetVariantNilPtr
}
*x, err = ParseMediaAssetVariant(*v)
default:
return errors.New("invalid type for MediaAssetVariant")
}
return
}
// Value implements the driver Valuer interface.
func (x MediaAssetVariant) Value() (driver.Value, error) {
return x.String(), nil
}
// Set implements the Golang flag.Value interface func.
func (x *MediaAssetVariant) Set(val string) error {
v, err := ParseMediaAssetVariant(val)
*x = v
return err
}
// Get implements the Golang flag.Getter interface func.
func (x *MediaAssetVariant) Get() interface{} {
return *x
}
// Type implements the github.com/spf13/pFlag Value interface.
func (x *MediaAssetVariant) Type() string {
return "MediaAssetVariant"
}
type NullMediaAssetVariant struct {
MediaAssetVariant MediaAssetVariant
Valid bool
}
func NewNullMediaAssetVariant(val interface{}) (x NullMediaAssetVariant) {
err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value.
_ = err // make any errcheck linters happy
return
}
// Scan implements the Scanner interface.
func (x *NullMediaAssetVariant) Scan(value interface{}) (err error) {
if value == nil {
x.MediaAssetVariant, x.Valid = MediaAssetVariant(""), false
return
}
err = x.MediaAssetVariant.Scan(value)
x.Valid = (err == nil)
return
}
// Value implements the driver Valuer interface.
func (x NullMediaAssetVariant) Value() (driver.Value, error) {
if !x.Valid {
return nil, nil
}
// driver.Value accepts int64 for int values.
return string(x.MediaAssetVariant), nil
}
type NullMediaAssetVariantStr struct {
NullMediaAssetVariant
}
func NewNullMediaAssetVariantStr(val interface{}) (x NullMediaAssetVariantStr) {
x.Scan(val) // yes, we ignore this error, it will just be an invalid value.
return
}
// Value implements the driver Valuer interface.
func (x NullMediaAssetVariantStr) Value() (driver.Value, error) {
if !x.Valid {
return nil, nil
}
return x.MediaAssetVariant.String(), nil
}
const (
// OrderStatusCreated is a OrderStatus of type created.
OrderStatusCreated OrderStatus = "created"

View File

@@ -185,6 +185,32 @@ func MediaAssetStatusItems() []requests.KV {
return items
}
// swagger:enum MediaAssetVariant
// ENUM( main, preview )
type MediaAssetVariant string
// Description returns the Chinese label for the specific enum value.
func (t MediaAssetVariant) Description() string {
switch t {
case MediaAssetVariantMain:
return "正片产物"
case MediaAssetVariantPreview:
return "试看产物"
default:
return "未知产物"
}
}
// MediaAssetVariantItems returns the KV list for FE dropdowns.
func MediaAssetVariantItems() []requests.KV {
values := MediaAssetVariantValues()
items := make([]requests.KV, 0, len(values))
for _, v := range values {
items = append(items, requests.NewKV(string(v), v.Description()))
}
return items
}
// contents
// swagger:enum ContentStatus