Files
quyun-v2/backend/app/services/content.go
2025-12-25 14:29:16 +08:00

893 lines
26 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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=previewmain/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 产物必须声明来源 mainmain/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=publictenant_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
}