Files
quyun-v2/backend/app/services/content.go
Rogee 39454458f1 feat: Implement public access for tenant content
- Add TenantOptionalAuth middleware to allow access to public content without requiring authentication.
- Introduce ListPublicPublished and PublicDetail methods in the content service to retrieve publicly accessible content.
- Create tenant_public HTTP routes for listing and showing public content, including preview and main asset retrieval.
- Enhance content tests to cover scenarios for public content access and permissions.
- Update specifications to reflect the new public content access features and rules.
2025-12-22 16:29:44 +08:00

532 lines
15 KiB
Go
Raw 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"
"errors"
"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"
"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
}
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
}
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")
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),
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
}