package services import ( "context" "encoding/json" "errors" "strings" "time" "quyun/v2/app/errorx" "quyun/v2/app/http/tenant/dto" "quyun/v2/app/requests" "quyun/v2/database" "quyun/v2/database/models" "quyun/v2/pkg/consts" pkgerrors "github.com/pkg/errors" "github.com/samber/lo" log "github.com/sirupsen/logrus" "go.ipao.vip/gen" "go.ipao.vip/gen/types" "gorm.io/gorm" ) // content 实现内容域相关的业务能力(创建/更新/定价/授权等)。 // // @provider type content struct{} // ContentDetailResult 为内容详情的内部结果(供 controller 组合返回)。 type ContentDetailResult struct { // Content 内容实体。 Content *models.Content // Price 定价信息(可能为 nil,表示未设置价格)。 Price *models.ContentPrice // HasAccess 当前用户是否拥有主资源访问权限。 HasAccess bool } // ContentPublishResult 为“内容发布(创建+绑定资源+定价)”的内部结果。 type ContentPublishResult struct { // Content 内容主体。 Content *models.Content // Price 定价信息。 Price *models.ContentPrice // CoverAssets 封面图绑定结果(role=cover)。 CoverAssets []*models.ContentAsset // MainAssets 主资源绑定结果(role=main)。 MainAssets []*models.ContentAsset // ContentTypes 内容类型列表:text/audio/video/image/multi_image(用于前端展示)。 ContentTypes []string } func requiredMediaAssetVariantForRole(role consts.ContentAssetRole) consts.MediaAssetVariant { switch role { case consts.ContentAssetRolePreview: return consts.MediaAssetVariantPreview default: // main/cover 一律要求 main 产物,避免误把 preview 绑定成正片/封面。 return consts.MediaAssetVariantMain } } func (s *content) Create(ctx context.Context, tenantID, userID int64, form *dto.ContentCreateForm) (*models.Content, error) { log.WithFields(log.Fields{ "tenant_id": tenantID, "user_id": userID, }).Info("services.content.create") // 关键默认值:未传可见性时默认“租户内可见”。 visibility := form.Visibility if visibility == "" { visibility = consts.ContentVisibilityTenantOnly } // 试看策略:默认固定时长;并强制不允许下载。 previewSeconds := consts.DefaultContentPreviewSeconds if form.PreviewSeconds != nil && *form.PreviewSeconds > 0 { previewSeconds = *form.PreviewSeconds } m := &models.Content{ TenantID: tenantID, UserID: userID, Title: form.Title, Description: form.Description, Status: consts.ContentStatusDraft, Visibility: visibility, PreviewSeconds: previewSeconds, PreviewDownloadable: false, } if err := m.Create(ctx); err != nil { return nil, pkgerrors.Wrap(err, "create content failed") } return m, nil } // Publish 租户管理员发布内容(创建内容 + 绑定封面/主资源 + 定价)。 // 说明:此接口面向“创作者/租户管理员”的内容发布场景,支持多种内容类型组合存在。 func (s *content) Publish(ctx context.Context, tenantID, userID int64, form *dto.ContentPublishForm) (*ContentPublishResult, error) { if tenantID <= 0 || userID <= 0 { return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0") } if form == nil { return nil, errorx.ErrMissingParameter.WithMsg("form is required") } title := strings.TrimSpace(form.Title) if title == "" { return nil, errorx.ErrMissingParameter.WithMsg("请填写标题") } summary := strings.TrimSpace(form.Summary) if len([]rune(summary)) > 256 { return nil, errorx.ErrInvalidParameter.WithMsg("简介过长(建议不超过 256 字符)") } detail := strings.TrimSpace(form.Detail) if len(form.CoverAssetIDs) < 1 || len(form.CoverAssetIDs) > 3 { return nil, errorx.ErrInvalidParameter.WithMsg("展示图需为 1-3 张") } hasText := detail != "" hasAudio := len(form.AudioAssetIDs) > 0 hasVideo := len(form.VideoAssetIDs) > 0 hasImage := len(form.ImageAssetIDs) > 0 if !hasText && !hasAudio && !hasVideo && !hasImage { return nil, errorx.ErrInvalidParameter.WithMsg("请至少提供一种内容类型(文字/音频/视频/多图)") } visibility := form.Visibility if visibility == "" { visibility = consts.ContentVisibilityTenantOnly } previewSeconds := consts.DefaultContentPreviewSeconds if form.PreviewSeconds != nil && *form.PreviewSeconds > 0 { previewSeconds = *form.PreviewSeconds } currency := form.Currency if currency == "" { currency = consts.CurrencyCNY } if form.PriceAmount < 0 { return nil, errorx.ErrInvalidParameter.WithMsg("价格不合法(需为 0 或正整数)") } // 标签:trim + 去重;限制数量与长度,避免滥用导致索引/存储膨胀。 tags := make([]string, 0, len(form.Tags)) seenTag := map[string]struct{}{} for _, raw := range form.Tags { v := strings.TrimSpace(raw) if v == "" { continue } if len([]rune(v)) > 20 { return nil, errorx.ErrInvalidParameter.WithMsg("标签过长(单个标签建议不超过 20 字符)") } if _, ok := seenTag[v]; ok { continue } seenTag[v] = struct{}{} tags = append(tags, v) if len(tags) >= 20 { return nil, errorx.ErrInvalidParameter.WithMsg("标签数量过多(建议不超过 20 个)") } } tagBytes, _ := json.Marshal(tags) if len(tagBytes) == 0 { tagBytes = []byte("[]") } // 资源去重与批量拉取。 allAssetIDs := make([]int64, 0, len(form.CoverAssetIDs)+len(form.AudioAssetIDs)+len(form.VideoAssetIDs)+len(form.ImageAssetIDs)) assetSeen := map[int64]struct{}{} addIDs := func(ids []int64) error { for _, id := range ids { if id <= 0 { return errorx.ErrInvalidParameter.WithMsg("资源ID不合法") } if _, ok := assetSeen[id]; ok { return errorx.ErrInvalidParameter.WithMsg("同一资源不可重复绑定(封面/主资源之间也不可重复)") } assetSeen[id] = struct{}{} allAssetIDs = append(allAssetIDs, id) } return nil } if err := addIDs(form.CoverAssetIDs); err != nil { return nil, err } if err := addIDs(form.AudioAssetIDs); err != nil { return nil, err } if err := addIDs(form.VideoAssetIDs); err != nil { return nil, err } if err := addIDs(form.ImageAssetIDs); err != nil { return nil, err } out := &ContentPublishResult{} log.WithFields(log.Fields{ "tenant_id": tenantID, "user_id": userID, "title": title, "price": form.PriceAmount, }).Info("services.content.publish") err := models.Q.Transaction(func(tx *models.Query) error { // 1) 校验资源(必须属于租户、未删除、ready、variant=main) assetTbl, assetQuery := tx.MediaAsset.QueryContext(ctx) assets, err := assetQuery.Where( assetTbl.TenantID.Eq(tenantID), assetTbl.ID.In(allAssetIDs...), assetTbl.DeletedAt.IsNull(), ).Find() if err != nil { return err } assetMap := make(map[int64]*models.MediaAsset, len(assets)) for _, a := range assets { if a == nil { continue } assetMap[a.ID] = a } for _, id := range allAssetIDs { a := assetMap[id] if a == nil { return errorx.ErrRecordNotFound.WithMsg("资源不存在或无权限访问") } if a.Status != consts.MediaAssetStatusReady { return errorx.ErrPreconditionFailed.WithMsg("存在未处理完成的资源,请稍后再试") } if a.Variant != consts.MediaAssetVariantMain { return errorx.ErrInvalidParameter.WithMsg("资源产物类型不正确(需为正片 main)") } } // 2) 创建内容(默认进入审核中) content := &models.Content{ TenantID: tenantID, UserID: userID, Title: title, Summary: summary, Description: detail, Tags: types.JSON(tagBytes), Status: consts.ContentStatusReviewing, Visibility: visibility, PreviewSeconds: previewSeconds, PreviewDownloadable: false, } if err := tx.Content.WithContext(ctx).Create(content); err != nil { return err } // 3) 创建定价(固定 CNY,折扣默认 none) price := &models.ContentPrice{ TenantID: tenantID, UserID: userID, ContentID: content.ID, Currency: currency, PriceAmount: form.PriceAmount, DiscountType: consts.DiscountTypeNone, DiscountValue: 0, DiscountStartAt: time.Time{}, DiscountEndAt: time.Time{}, } if err := tx.ContentPrice.WithContext(ctx).Create(price); err != nil { return err } // 4) 绑定封面图(role=cover) coverAssets := make([]*models.ContentAsset, 0, len(form.CoverAssetIDs)) for i, id := range form.CoverAssetIDs { a := assetMap[id] if a.Type != consts.MediaAssetTypeImage { return errorx.ErrInvalidParameter.WithMsg("展示图必须为图片资源") } ca := &models.ContentAsset{ TenantID: tenantID, UserID: userID, ContentID: content.ID, AssetID: id, Role: consts.ContentAssetRoleCover, Sort: int32(i), } if err := tx.ContentAsset.WithContext(ctx).Create(ca); err != nil { return err } coverAssets = append(coverAssets, ca) } // 5) 绑定主资源(role=main;支持音频/视频/多图组合) mainAssets := make([]*models.ContentAsset, 0, len(form.AudioAssetIDs)+len(form.VideoAssetIDs)+len(form.ImageAssetIDs)) sort := int32(0) attachMain := func(ids []int64, wantType consts.MediaAssetType) error { for _, id := range ids { a := assetMap[id] if a.Type != wantType { return errorx.ErrInvalidParameter.WithMsg("主资源类型与选择不匹配") } ca := &models.ContentAsset{ TenantID: tenantID, UserID: userID, ContentID: content.ID, AssetID: id, Role: consts.ContentAssetRoleMain, Sort: sort, } if err := tx.ContentAsset.WithContext(ctx).Create(ca); err != nil { return err } mainAssets = append(mainAssets, ca) sort++ } return nil } // 顺序:视频 -> 音频 -> 图片(多图) if err := attachMain(form.VideoAssetIDs, consts.MediaAssetTypeVideo); err != nil { return err } if err := attachMain(form.AudioAssetIDs, consts.MediaAssetTypeAudio); err != nil { return err } if err := attachMain(form.ImageAssetIDs, consts.MediaAssetTypeImage); err != nil { return err } typesOut := make([]string, 0, 4) if hasText { typesOut = append(typesOut, "text") } if hasAudio { typesOut = append(typesOut, "audio") } if hasVideo { typesOut = append(typesOut, "video") } if hasImage { if len(form.ImageAssetIDs) >= 2 { typesOut = append(typesOut, "multi_image") } else { typesOut = append(typesOut, "image") } } out.Content = content out.Price = price out.CoverAssets = coverAssets out.MainAssets = mainAssets out.ContentTypes = typesOut return nil }) if err != nil { return nil, err } return out, nil } func (s *content) Update(ctx context.Context, tenantID, userID, contentID int64, form *dto.ContentUpdateForm) (*models.Content, error) { log.WithFields(log.Fields{ "tenant_id": tenantID, "user_id": userID, "content_id": contentID, }).Info("services.content.update") tbl, query := models.ContentQuery.QueryContext(ctx) m, err := query.Where( tbl.TenantID.Eq(tenantID), tbl.ID.Eq(contentID), ).First() if err != nil { return nil, pkgerrors.Wrap(err, "content not found") } if form.Title != nil { m.Title = *form.Title } if form.Description != nil { m.Description = *form.Description } if form.Visibility != nil { m.Visibility = *form.Visibility } if form.PreviewSeconds != nil && *form.PreviewSeconds > 0 { m.PreviewSeconds = *form.PreviewSeconds m.PreviewDownloadable = false } if form.Status != nil { m.Status = *form.Status // 发布动作:首次发布时补齐发布时间,便于后续排序/检索与审计。 if m.Status == consts.ContentStatusPublished && m.PublishedAt.IsZero() { m.PublishedAt = time.Now() } } if _, err := m.Update(ctx); err != nil { return nil, pkgerrors.Wrap(err, "update content failed") } return m, nil } func (s *content) UpsertPrice(ctx context.Context, tenantID, userID, contentID int64, form *dto.ContentPriceUpsertForm) (*models.ContentPrice, error) { log.WithFields(log.Fields{ "tenant_id": tenantID, "user_id": userID, "content_id": contentID, "amount": form.PriceAmount, }).Info("services.content.upsert_price") currency := form.Currency if currency == "" { currency = consts.CurrencyCNY } discountType := form.DiscountType if discountType == "" { discountType = consts.DiscountTypeNone } startAt := time.Time{} if form.DiscountStartAt != nil { startAt = *form.DiscountStartAt } endAt := time.Time{} if form.DiscountEndAt != nil { endAt = *form.DiscountEndAt } tbl, query := models.ContentPriceQuery.QueryContext(ctx) m, err := query.Where( tbl.TenantID.Eq(tenantID), tbl.ContentID.Eq(contentID), ).First() if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, pkgerrors.Wrap(err, "find content price failed") } if errors.Is(err, gorm.ErrRecordNotFound) { m = &models.ContentPrice{ TenantID: tenantID, UserID: userID, ContentID: contentID, Currency: currency, PriceAmount: form.PriceAmount, DiscountType: discountType, DiscountValue: form.DiscountValue, DiscountStartAt: startAt, DiscountEndAt: endAt, } if err := m.Create(ctx); err != nil { return nil, pkgerrors.Wrap(err, "create content price failed") } return m, nil } m.UserID = userID m.Currency = currency m.PriceAmount = form.PriceAmount m.DiscountType = discountType m.DiscountValue = form.DiscountValue m.DiscountStartAt = startAt m.DiscountEndAt = endAt if _, err := m.Update(ctx); err != nil { return nil, pkgerrors.Wrap(err, "update content price failed") } return m, nil } func (s *content) AttachAsset(ctx context.Context, tenantID, userID, contentID, assetID int64, role consts.ContentAssetRole, sort int32, now time.Time) (*models.ContentAsset, error) { log.WithFields(log.Fields{ "tenant_id": tenantID, "user_id": userID, "content_id": contentID, "asset_id": assetID, "role": role, "sort": sort, }).Info("services.content.attach_asset") // 约束:只能绑定本租户内、且已处理完成(ready)的资源;避免未完成处理的资源对外可见。 tblContent, queryContent := models.ContentQuery.QueryContext(ctx) if _, err := queryContent.Where( tblContent.TenantID.Eq(tenantID), tblContent.ID.Eq(contentID), ).First(); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.ErrRecordNotFound.WithMsg("content not found") } return nil, err } tblAsset, queryAsset := models.MediaAssetQuery.QueryContext(ctx) asset, err := queryAsset.Where( tblAsset.TenantID.Eq(tenantID), tblAsset.ID.Eq(assetID), tblAsset.DeletedAt.IsNull(), ).First() if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.ErrRecordNotFound.WithMsg("media asset not found") } return nil, err } if asset.Status != consts.MediaAssetStatusReady { return nil, errorx.ErrPreconditionFailed.WithMsg("media asset not ready") } // C2 规则:preview 必须绑定独立产物(media_assets.variant=preview),main/cover 必须为 main。 variant := asset.Variant if variant == "" { variant = consts.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 asset.SourceAssetID <= 0 { return nil, errorx.ErrPreconditionFailed.WithMsg("preview asset must have source_asset_id") } 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 := src.Variant if srcVariant == "" { srcVariant = consts.MediaAssetVariantMain } if srcVariant != consts.MediaAssetVariantMain { return nil, errorx.ErrPreconditionFailed.WithMsg("preview source asset must be main variant") } } else { if asset.SourceAssetID > 0 { return nil, errorx.ErrPreconditionFailed.WithMsg("main/cover asset must not have source_asset_id") } } m := &models.ContentAsset{ TenantID: tenantID, UserID: userID, ContentID: contentID, AssetID: assetID, Role: role, Sort: sort, CreatedAt: now, UpdatedAt: now, } if err := m.Create(ctx); err != nil { return nil, pkgerrors.Wrap(err, "attach content asset failed") } return m, nil } func (s *content) ListPublished(ctx context.Context, tenantID, userID int64, filter *dto.ContentListFilter) (*requests.Pager, error) { log.WithFields(log.Fields{ "tenant_id": tenantID, "user_id": userID, "page": filter.Page, "limit": filter.Limit, }).Info("services.content.list_published") tbl, query := models.ContentQuery.QueryContext(ctx) conds := []gen.Condition{ tbl.TenantID.Eq(tenantID), tbl.Status.Eq(consts.ContentStatusPublished), tbl.Visibility.In(consts.ContentVisibilityPublic, consts.ContentVisibilityTenantOnly), } if filter.Keyword != nil && *filter.Keyword != "" { conds = append(conds, tbl.Title.Like(database.WrapLike(*filter.Keyword))) } filter.Pagination.Format() items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) if err != nil { return nil, err } contentIDs := lo.Map(items, func(item *models.Content, _ int) int64 { return item.ID }) priceByContent, err := s.contentPriceMapping(ctx, tenantID, contentIDs) if err != nil { return nil, err } accessSet, err := s.accessSet(ctx, tenantID, userID, contentIDs) if err != nil { return nil, err } respItems := lo.Map(items, func(model *models.Content, _ int) *dto.ContentItem { price := priceByContent[model.ID] free := price == nil || price.PriceAmount == 0 has := free || accessSet[model.ID] || model.UserID == userID return &dto.ContentItem{ Content: model, Price: price, HasAccess: has, } }) return &requests.Pager{ Pagination: filter.Pagination, Total: total, Items: respItems, }, nil } // ListPublicPublished 返回“公开可见”的已发布内容列表(给游客/非成员使用)。 // 规则:仅返回 published + visibility=public;tenant_only/private 永不通过公开接口暴露。 func (s *content) ListPublicPublished(ctx context.Context, tenantID, viewerUserID int64, filter *dto.ContentListFilter) (*requests.Pager, error) { if filter == nil { filter = &dto.ContentListFilter{} } log.WithFields(log.Fields{ "tenant_id": tenantID, "user_id": viewerUserID, "page": filter.Page, "limit": filter.Limit, }).Info("services.content.list_public_published") tbl, query := models.ContentQuery.QueryContext(ctx) conds := []gen.Condition{ tbl.TenantID.Eq(tenantID), tbl.Status.Eq(consts.ContentStatusPublished), tbl.Visibility.Eq(consts.ContentVisibilityPublic), tbl.DeletedAt.IsNull(), } if filter.Keyword != nil && *filter.Keyword != "" { conds = append(conds, tbl.Title.Like(database.WrapLike(*filter.Keyword))) } filter.Pagination.Format() items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) if err != nil { return nil, err } contentIDs := lo.Map(items, func(item *models.Content, _ int) int64 { return item.ID }) priceByContent, err := s.contentPriceMapping(ctx, tenantID, contentIDs) if err != nil { return nil, err } accessSet := map[int64]bool{} if viewerUserID > 0 { m, err := s.accessSet(ctx, tenantID, viewerUserID, contentIDs) if err != nil { return nil, err } accessSet = m } respItems := lo.Map(items, func(model *models.Content, _ int) *dto.ContentItem { price := priceByContent[model.ID] free := price == nil || price.PriceAmount == 0 has := free || accessSet[model.ID] || model.UserID == viewerUserID return &dto.ContentItem{ Content: model, Price: price, HasAccess: has, } }) return &requests.Pager{ Pagination: filter.Pagination, Total: total, Items: respItems, }, nil } // PublicDetail 返回“公开可见”的内容详情(给游客/非成员使用)。 // 规则:仅允许 published + visibility=public;否则统一返回 not found,避免信息泄露。 func (s *content) PublicDetail(ctx context.Context, tenantID, viewerUserID, contentID int64) (*ContentDetailResult, error) { log.WithFields(log.Fields{ "tenant_id": tenantID, "user_id": viewerUserID, "content_id": contentID, }).Info("services.content.public_detail") tbl, query := models.ContentQuery.QueryContext(ctx) model, err := query.Where( tbl.TenantID.Eq(tenantID), tbl.ID.Eq(contentID), tbl.DeletedAt.IsNull(), ).First() if err != nil { return nil, errorx.ErrRecordNotFound.WithMsg("content not found") } // Public endpoints only expose published + public contents. if model.Status != consts.ContentStatusPublished || model.Visibility != consts.ContentVisibilityPublic { return nil, errorx.ErrRecordNotFound.WithMsg("content not found") } price, err := s.contentPrice(ctx, tenantID, contentID) if err != nil { return nil, err } free := price == nil || price.PriceAmount == 0 hasAccess := model.UserID == viewerUserID || free if !hasAccess && viewerUserID > 0 { ok, err := s.HasAccess(ctx, tenantID, viewerUserID, contentID) if err != nil { return nil, err } hasAccess = ok } return &ContentDetailResult{ Content: model, Price: price, HasAccess: hasAccess, }, nil } func (s *content) Detail(ctx context.Context, tenantID, userID, contentID int64) (*ContentDetailResult, error) { log.WithFields(log.Fields{ "tenant_id": tenantID, "user_id": userID, "content_id": contentID, }).Info("services.content.detail") tbl, query := models.ContentQuery.QueryContext(ctx) model, err := query.Where( tbl.TenantID.Eq(tenantID), tbl.ID.Eq(contentID), ).First() if err != nil { return nil, pkgerrors.Wrapf(err, "content not found, tenantID=%d, contentID=%d", tenantID, contentID) } if model.Status != consts.ContentStatusPublished && model.UserID != userID { return nil, errors.New("content is not published") } price, err := s.contentPrice(ctx, tenantID, contentID) if err != nil { return nil, err } free := price == nil || price.PriceAmount == 0 canView := false switch model.Visibility { case consts.ContentVisibilityPublic, consts.ContentVisibilityTenantOnly: canView = true case consts.ContentVisibilityPrivate: canView = model.UserID == userID default: canView = false } hasAccess := model.UserID == userID || free if !hasAccess { ok, err := s.HasAccess(ctx, tenantID, userID, contentID) if err != nil { return nil, err } hasAccess = ok canView = canView || ok } if !canView { return nil, errors.New("content is private") } return &ContentDetailResult{ Content: model, Price: price, HasAccess: hasAccess, }, nil } func (s *content) HasAccess(ctx context.Context, tenantID, userID, contentID int64) (bool, error) { log.WithFields(log.Fields{ "tenant_id": tenantID, "user_id": userID, "content_id": contentID, }).Info("services.content.has_access") tbl, query := models.ContentAccessQuery.QueryContext(ctx) _, err := query.Where( tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID), tbl.ContentID.Eq(contentID), tbl.Status.Eq(consts.ContentAccessStatusActive), ).First() if err != nil { return false, nil } return true, nil } func (s *content) AssetsByRole(ctx context.Context, tenantID, contentID int64, role consts.ContentAssetRole) ([]*models.MediaAsset, error) { log.WithFields(log.Fields{ "tenant_id": tenantID, "content_id": contentID, "role": role, }).Info("services.content.assets_by_role") maTbl, maQuery := models.MediaAssetQuery.QueryContext(ctx) caTbl, _ := models.ContentAssetQuery.QueryContext(ctx) assets, err := maQuery. LeftJoin(caTbl, caTbl.AssetID.EqCol(maTbl.ID)). Select(maTbl.ALL). Where( maTbl.TenantID.Eq(tenantID), maTbl.DeletedAt.IsNull(), maTbl.Status.Eq(consts.MediaAssetStatusReady), caTbl.TenantID.Eq(tenantID), caTbl.ContentID.Eq(contentID), caTbl.Role.Eq(role), ). Order(caTbl.Sort.Asc()). Find() if err != nil { return nil, err } return assets, nil } func (s *content) contentPrice(ctx context.Context, tenantID, contentID int64) (*models.ContentPrice, error) { tbl, query := models.ContentPriceQuery.QueryContext(ctx) m, err := query.Where( tbl.TenantID.Eq(tenantID), tbl.ContentID.Eq(contentID), ).First() if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } return m, nil } func (s *content) contentPriceMapping(ctx context.Context, tenantID int64, contentIDs []int64) (map[int64]*models.ContentPrice, error) { if len(contentIDs) == 0 { return map[int64]*models.ContentPrice{}, nil } tbl, query := models.ContentPriceQuery.QueryContext(ctx) items, err := query.Where( tbl.TenantID.Eq(tenantID), tbl.ContentID.In(contentIDs...), ).Find() if err != nil { return nil, err } return lo.SliceToMap(items, func(item *models.ContentPrice) (int64, *models.ContentPrice) { return item.ContentID, item }), nil } func (s *content) accessSet(ctx context.Context, tenantID, userID int64, contentIDs []int64) (map[int64]bool, error) { if len(contentIDs) == 0 { return map[int64]bool{}, nil } tbl, query := models.ContentAccessQuery.QueryContext(ctx) items, err := query.Where( tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID), tbl.ContentID.In(contentIDs...), tbl.Status.Eq(consts.ContentAccessStatusActive), ).Find() if err != nil { return nil, err } out := make(map[int64]bool, len(items)) for _, item := range items { out[item.ContentID] = true } return out, nil }