package services import ( "context" "errors" "quyun/v2/app/errorx" content_dto "quyun/v2/app/http/v1/dto" user_dto "quyun/v2/app/http/v1/dto" "quyun/v2/app/requests" "quyun/v2/database/models" "quyun/v2/pkg/consts" "go.ipao.vip/gen/types" "gorm.io/gorm" ) // @provider type content struct{} func (s *content) List(ctx context.Context, tenantID int64, filter *content_dto.ContentListFilter) (*requests.Pager, error) { tbl, q := models.ContentQuery.QueryContext(ctx) // Filters if filter.Keyword != nil && *filter.Keyword != "" { keyword := "%" + *filter.Keyword + "%" q = q.Where(tbl.Title.Like(keyword)).Or(tbl.Description.Like(keyword)) } q = q.Where(tbl.Status.Eq(consts.ContentStatusPublished)) if tenantID > 0 { q = q.Where(tbl.TenantID.Eq(tenantID)) } if filter.Genre != nil && *filter.Genre != "" { q = q.Where(tbl.Genre.Eq(*filter.Genre)) } if filter.TenantID != nil && *filter.TenantID > 0 { if tenantID > 0 && *filter.TenantID != tenantID { return nil, errorx.ErrForbidden.WithMsg("租户不匹配") } q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) } if filter.IsPinned != nil { q = q.Where(tbl.IsPinned.Is(*filter.IsPinned)) } if filter.PriceType != nil && *filter.PriceType != "all" { if *filter.PriceType == "member" { q = q.Where(tbl.Visibility.Eq(consts.ContentVisibilityTenantOnly)) } else { pTbl, pQ := models.ContentPriceQuery.QueryContext(ctx) var shouldFilter bool var prices []*models.ContentPrice if *filter.PriceType == "free" { shouldFilter = true prices, _ = pQ.Where(pTbl.PriceAmount.Eq(0)).Select(pTbl.ContentID).Find() } else if *filter.PriceType == "paid" { shouldFilter = true prices, _ = pQ.Where(pTbl.PriceAmount.Gt(0)).Select(pTbl.ContentID).Find() } if shouldFilter { ids := make([]int64, len(prices)) for i, p := range prices { ids[i] = p.ContentID } if len(ids) > 0 { q = q.Where(tbl.ID.In(ids...)) } else { q = q.Where(tbl.ID.Eq(-1)) } } } } // Sort sort := "latest" if filter.Sort != nil && *filter.Sort != "" { sort = *filter.Sort } switch sort { case "hot": q = q.Order(tbl.Views.Desc()) case "price_asc": q = q.Order(tbl.ID.Desc()) default: // latest q = q.Order(tbl.ID.Desc()) } // Pagination filter.Pagination.Format() total, err := q.Count() if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } // Use UnderlyingDB for complex preloads var list []*models.Content err = q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)). UnderlyingDB(). Preload("Author"). Preload("ContentAssets.Asset"). Find(&list).Error if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } // Fetch Prices priceMap := make(map[int64]float64) if len(list) > 0 { ids := make([]int64, len(list)) for i, item := range list { ids[i] = item.ID } pTbl, pQ := models.ContentPriceQuery.QueryContext(ctx) prices, _ := pQ.Where(pTbl.ContentID.In(ids...)).Find() for _, p := range prices { priceMap[p.ContentID] = float64(p.PriceAmount) / 100.0 } } // Convert to DTO data := make([]content_dto.ContentItem, len(list)) for i, item := range list { data[i] = s.toContentItemDTO(item, priceMap[item.ID], false) } return &requests.Pager{ Pagination: filter.Pagination, Total: total, Items: data, }, nil } func (s *content) Get(ctx context.Context, tenantID, userID, id int64) (*content_dto.ContentDetail, error) { // Increment Views update := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(id)) if tenantID > 0 { update = update.Where(models.ContentQuery.TenantID.Eq(tenantID)) } _, _ = update.UpdateSimple(models.ContentQuery.Views.Add(1)) _, q := models.ContentQuery.QueryContext(ctx) var item models.Content db := q.UnderlyingDB() if tenantID > 0 { db = db.Where("tenant_id = ?", tenantID) } err := db. Preload("Author"). Preload("ContentAssets", func(db *gorm.DB) *gorm.DB { return db.Order("sort ASC") }). Preload("ContentAssets.Asset"). Where("id = ?", id). First(&item).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.ErrRecordNotFound } return nil, errorx.ErrDatabaseError.WithCause(err) } // Fetch Price var price float64 cp, err := models.ContentPriceQuery.WithContext(ctx).Where(models.ContentPriceQuery.ContentID.Eq(id)).First() if err == nil { price = float64(cp.PriceAmount) / 100.0 } // Interaction & Access status isLiked := false isFavorited := false hasAccess := false if userID > 0 { uid := userID // Interaction isLiked, _ = models.UserContentActionQuery.WithContext(ctx). Where(models.UserContentActionQuery.UserID.Eq(uid), models.UserContentActionQuery.ContentID.Eq(id), models.UserContentActionQuery.Type.Eq("like")). Exists() isFavorited, _ = models.UserContentActionQuery.WithContext(ctx). Where(models.UserContentActionQuery.UserID.Eq(uid), models.UserContentActionQuery.ContentID.Eq(id), models.UserContentActionQuery.Type.Eq("favorite")). Exists() // Access Check if item.UserID == uid { hasAccess = true // Owner } else { // Check Purchase exists, _ := models.ContentAccessQuery.WithContext(ctx). Where(models.ContentAccessQuery.UserID.Eq(uid), models.ContentAccessQuery.ContentID.Eq(id), models.ContentAccessQuery.Status.Eq(consts.ContentAccessStatusActive)). Exists() if exists { hasAccess = true } } } // Filter Assets based on Access var accessibleAssets []*models.ContentAsset for _, ca := range item.ContentAssets { if hasAccess { accessibleAssets = append(accessibleAssets, ca) } else { // If no access, only allow Preview and Cover if ca.Role == consts.ContentAssetRolePreview || ca.Role == consts.ContentAssetRoleCover { accessibleAssets = append(accessibleAssets, ca) } } } // Check if author is followed authorIsFollowing := false if userID > 0 { exists, _ := models.TenantUserQuery.WithContext(ctx). Where(models.TenantUserQuery.TenantID.Eq(item.TenantID), models.TenantUserQuery.Role.Contains(types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember})). Exists() authorIsFollowing = exists } detail := &content_dto.ContentDetail{ ContentItem: s.toContentItemDTO(&item, price, authorIsFollowing), Description: item.Description, Body: item.Body, MediaUrls: s.toMediaURLs(accessibleAssets), Meta: content_dto.Meta{Key: item.Key}, IsLiked: isLiked, IsFavorited: isFavorited, } // Pass IsPurchased/HasAccess info to frontend? detail.ContentItem.IsPurchased = hasAccess // Update DTO field logic if needed. IsPurchased usually means "Bought". Owner implies access but not necessarily purchased. But for UI "Play" button, IsPurchased=true is fine. return detail, nil } func (s *content) ListComments(ctx context.Context, tenantID, userID, id int64, page int) (*requests.Pager, error) { if tenantID > 0 { _, err := models.ContentQuery.WithContext(ctx). Where(models.ContentQuery.ID.Eq(id), models.ContentQuery.TenantID.Eq(tenantID)). First() if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.ErrRecordNotFound } return nil, errorx.ErrDatabaseError.WithCause(err) } } tbl, q := models.CommentQuery.QueryContext(ctx) q = q.Where(tbl.ContentID.Eq(id)).Preload(tbl.User) if tenantID > 0 { q = q.Where(tbl.TenantID.Eq(tenantID)) } q = q.Order(tbl.CreatedAt.Desc()) p := requests.Pagination{Page: int64(page), Limit: 10} total, err := q.Count() if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } list, err := q.Offset(int(p.Offset())).Limit(int(p.Limit)).Find() if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } // User likes likedMap := make(map[int64]bool) if userID > 0 { uid := userID ids := make([]int64, len(list)) for i, v := range list { ids[i] = v.ID } likes, _ := models.UserCommentActionQuery.WithContext(ctx). Where(models.UserCommentActionQuery.UserID.Eq(uid), models.UserCommentActionQuery.CommentID.In(ids...), models.UserCommentActionQuery.Type.Eq("like")). Find() for _, l := range likes { likedMap[l.CommentID] = true } } data := make([]content_dto.Comment, len(list)) for i, v := range list { data[i] = content_dto.Comment{ ID: v.ID, Content: v.Content, UserID: v.UserID, UserNickname: v.User.Nickname, UserAvatar: v.User.Avatar, CreateTime: v.CreatedAt.Format("2006-01-02 15:04:05"), Likes: int(v.Likes), ReplyTo: v.ReplyTo, IsLiked: likedMap[v.ID], } } return &requests.Pager{ Pagination: requests.Pagination{Page: p.Page, Limit: p.Limit}, Total: total, Items: data, }, nil } func (s *content) CreateComment( ctx context.Context, tenantID int64, userID int64, id int64, form *content_dto.CommentCreateForm, ) error { if userID == 0 { return errorx.ErrUnauthorized } uid := userID query := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(id)) if tenantID > 0 { query = query.Where(models.ContentQuery.TenantID.Eq(tenantID)) } c, err := query.First() if err != nil { return errorx.ErrRecordNotFound } comment := &models.Comment{ TenantID: c.TenantID, UserID: uid, ContentID: id, Content: form.Content, ReplyTo: form.ReplyTo, } if err := models.CommentQuery.WithContext(ctx).Create(comment); err != nil { return errorx.ErrDatabaseError.WithCause(err) } return nil } func (s *content) LikeComment(ctx context.Context, tenantID, userID, id int64) error { if userID == 0 { return errorx.ErrUnauthorized } uid := userID // Fetch comment for author query := models.CommentQuery.WithContext(ctx).Where(models.CommentQuery.ID.Eq(id)) if tenantID > 0 { query = query.Where(models.CommentQuery.TenantID.Eq(tenantID)) } cm, err := query.First() if err != nil { return errorx.ErrRecordNotFound } err = models.Q.Transaction(func(tx *models.Query) error { exists, _ := tx.UserCommentAction.WithContext(ctx). Where(tx.UserCommentAction.UserID.Eq(uid), tx.UserCommentAction.CommentID.Eq(id), tx.UserCommentAction.Type.Eq("like")). Exists() if exists { return nil } action := &models.UserCommentAction{UserID: uid, CommentID: id, Type: "like"} if err := tx.UserCommentAction.WithContext(ctx).Create(action); err != nil { return err } _, err := tx.Comment.WithContext(ctx).Where(tx.Comment.ID.Eq(id)).UpdateSimple(tx.Comment.Likes.Add(1)) return err }) if err != nil { return err } if Notification != nil { _ = Notification.Send(ctx, tenantID, cm.UserID, "interaction", "评论点赞", "有人点赞了您的评论") } return nil } func (s *content) GetLibrary(ctx context.Context, tenantID, userID int64) ([]user_dto.ContentItem, error) { if userID == 0 { return nil, errorx.ErrUnauthorized } uid := userID tbl, q := models.ContentAccessQuery.QueryContext(ctx) q = q.Where(tbl.UserID.Eq(uid), tbl.Status.Eq(consts.ContentAccessStatusActive)) if tenantID > 0 { q = q.Where(tbl.TenantID.Eq(tenantID)) } accessList, err := q.Find() if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } if len(accessList) == 0 { return []user_dto.ContentItem{}, nil } var contentIDs []int64 for _, a := range accessList { contentIDs = append(contentIDs, a.ContentID) } ctbl, cq := models.ContentQuery.QueryContext(ctx) var list []*models.Content cq = cq.Where(ctbl.ID.In(contentIDs...)) if tenantID > 0 { cq = cq.Where(ctbl.TenantID.Eq(tenantID)) } err = cq. UnderlyingDB(). Preload("Author"). Preload("ContentAssets.Asset"). Find(&list).Error if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } var data []user_dto.ContentItem for _, item := range list { dto := s.toContentItemDTO(item, 0, false) dto.IsPurchased = true data = append(data, dto) } return data, nil } func (s *content) GetFavorites(ctx context.Context, tenantID, userID int64) ([]user_dto.ContentItem, error) { return s.getInteractList(ctx, tenantID, userID, "favorite") } func (s *content) AddFavorite(ctx context.Context, tenantID, userID, contentId int64) error { return s.addInteract(ctx, tenantID, userID, contentId, "favorite") } func (s *content) RemoveFavorite(ctx context.Context, tenantID, userID, contentId int64) error { return s.removeInteract(ctx, tenantID, userID, contentId, "favorite") } func (s *content) GetLikes(ctx context.Context, tenantID, userID int64) ([]user_dto.ContentItem, error) { return s.getInteractList(ctx, tenantID, userID, "like") } func (s *content) AddLike(ctx context.Context, tenantID, userID, contentId int64) error { return s.addInteract(ctx, tenantID, userID, contentId, "like") } func (s *content) RemoveLike(ctx context.Context, tenantID, userID, contentId int64) error { return s.removeInteract(ctx, tenantID, userID, contentId, "like") } func (s *content) ListTopics(ctx context.Context, tenantID int64) ([]content_dto.Topic, error) { var results []struct { Genre string Count int } db := models.ContentQuery.WithContext(ctx).UnderlyingDB() if tenantID > 0 { db = db.Where("tenant_id = ?", tenantID) } err := db. Model(&models.Content{}). Where("status = ?", consts.ContentStatusPublished). Select("genre, count(*) as count"). Group("genre"). Scan(&results).Error if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } var topics []content_dto.Topic for i, r := range results { if r.Genre == "" { continue } // Fetch latest content in this genre to get a cover var c models.Content query := models.ContentQuery.WithContext(ctx). Where(models.ContentQuery.Genre.Eq(r.Genre), models.ContentQuery.Status.Eq(consts.ContentStatusPublished)) if tenantID > 0 { query = query.Where(models.ContentQuery.TenantID.Eq(tenantID)) } query. Order(models.ContentQuery.PublishedAt.Desc()). UnderlyingDB(). Preload("ContentAssets"). Preload("ContentAssets.Asset"). First(&c) cover := "" for _, ca := range c.ContentAssets { if (ca.Role == consts.ContentAssetRoleCover || ca.Role == consts.ContentAssetRoleMain) && ca.Asset != nil { cover = Common.GetAssetURL(ca.Asset.ObjectKey) if ca.Role == consts.ContentAssetRoleCover { break // Prefer cover } } } topics = append(topics, content_dto.Topic{ ID: int64(i + 1), // Use index as ID for aggregation results Title: r.Genre, Tag: r.Genre, Count: r.Count, Cover: cover, }) } return topics, nil } // Helpers func (s *content) toContentItemDTO(item *models.Content, price float64, authorIsFollowing bool) content_dto.ContentItem { dto := content_dto.ContentItem{ ID: item.ID, TenantID: item.TenantID, UserID: item.UserID, Title: item.Title, Genre: item.Genre, Status: string(item.Status), Visibility: string(item.Visibility), AuthorID: item.UserID, Views: int(item.Views), Likes: int(item.Likes), CreatedAt: item.CreatedAt.Format("2006-01-02"), Price: price, AuthorIsFollowing: authorIsFollowing, } if !item.PublishedAt.IsZero() { dto.PublishedAt = item.PublishedAt.Format("2006-01-02") } if item.Author != nil { dto.AuthorName = item.Author.Nickname dto.AuthorAvatar = item.Author.Avatar } // Determine Type and Cover from assets var hasVideo, hasAudio bool for _, asset := range item.ContentAssets { if asset.Asset == nil { continue } // Cover if asset.Role == consts.ContentAssetRoleCover { dto.Cover = Common.GetAssetURL(asset.Asset.ObjectKey) } // Type detection switch asset.Asset.Type { case consts.MediaAssetTypeVideo: hasVideo = true case consts.MediaAssetTypeAudio: hasAudio = true } } // Fallback for cover if not explicitly set as cover role (take first image or whatever) if dto.Cover == "" && len(item.ContentAssets) > 0 { for _, asset := range item.ContentAssets { if asset.Asset != nil && asset.Asset.Type == consts.MediaAssetTypeImage { dto.Cover = Common.GetAssetURL(asset.Asset.ObjectKey) break } } } if hasVideo { dto.Type = "video" } else if hasAudio { dto.Type = "audio" } else { dto.Type = "article" } return dto } func (s *content) toMediaURLs(assets []*models.ContentAsset) []content_dto.MediaURL { var urls []content_dto.MediaURL for _, ca := range assets { if ca.Asset != nil { url := Common.GetAssetURL(ca.Asset.ObjectKey) urls = append(urls, content_dto.MediaURL{ Type: string(ca.Asset.Type), URL: url, }) } } return urls } func (s *content) addInteract(ctx context.Context, tenantID, userID, contentId int64, typ string) error { if userID == 0 { return errorx.ErrUnauthorized } uid := userID // Fetch content for author query := models.ContentQuery.WithContext(ctx). Where(models.ContentQuery.ID.Eq(contentId)) if tenantID > 0 { query = query.Where(models.ContentQuery.TenantID.Eq(tenantID)) } c, err := query. Select(models.ContentQuery.UserID, models.ContentQuery.Title). First() if err != nil { return errorx.ErrRecordNotFound } err = models.Q.Transaction(func(tx *models.Query) error { exists, _ := tx.UserContentAction.WithContext(ctx). Where(tx.UserContentAction.UserID.Eq(uid), tx.UserContentAction.ContentID.Eq(contentId), tx.UserContentAction.Type.Eq(typ)). Exists() if exists { return nil } action := &models.UserContentAction{UserID: uid, ContentID: contentId, Type: typ} if err := tx.UserContentAction.WithContext(ctx).Create(action); err != nil { return err } if typ == "like" { contentQuery := tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(contentId)) if tenantID > 0 { contentQuery = contentQuery.Where(tx.Content.TenantID.Eq(tenantID)) } _, err := contentQuery.UpdateSimple(tx.Content.Likes.Add(1)) return err } return nil }) if err != nil { return err } if Notification != nil { actionName := "互动" switch typ { case "like": actionName = "点赞" case "favorite": actionName = "收藏" } _ = Notification.Send(ctx, tenantID, c.UserID, "interaction", "新的"+actionName, "有人"+actionName+"了您的作品: "+c.Title) } return nil } func (s *content) removeInteract(ctx context.Context, tenantID, userID, contentId int64, typ string) error { if userID == 0 { return errorx.ErrUnauthorized } uid := userID return models.Q.Transaction(func(tx *models.Query) error { res, err := tx.UserContentAction.WithContext(ctx). Where(tx.UserContentAction.UserID.Eq(uid), tx.UserContentAction.ContentID.Eq(contentId), tx.UserContentAction.Type.Eq(typ)). Delete() if err != nil { return err } if res.RowsAffected == 0 { return nil } if typ == "like" { contentQuery := tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(contentId)) if tenantID > 0 { contentQuery = contentQuery.Where(tx.Content.TenantID.Eq(tenantID)) } _, err := contentQuery.UpdateSimple(tx.Content.Likes.Sub(1)) return err } return nil }) } func (s *content) getInteractList(ctx context.Context, tenantID, userID int64, typ string) ([]user_dto.ContentItem, error) { if userID == 0 { return nil, errorx.ErrUnauthorized } uid := userID tbl, q := models.UserContentActionQuery.QueryContext(ctx) actions, err := q.Where(tbl.UserID.Eq(uid), tbl.Type.Eq(typ)).Find() if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } if len(actions) == 0 { return []user_dto.ContentItem{}, nil } var contentIDs []int64 for _, a := range actions { contentIDs = append(contentIDs, a.ContentID) } ctbl, cq := models.ContentQuery.QueryContext(ctx) var list []*models.Content cq = cq.Where(ctbl.ID.In(contentIDs...)) if tenantID > 0 { cq = cq.Where(ctbl.TenantID.Eq(tenantID)) } err = cq. UnderlyingDB(). Preload("Author"). Preload("ContentAssets.Asset"). Find(&list).Error if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } var data []user_dto.ContentItem for _, item := range list { data = append(data, s.toContentItemDTO(item, 0, false)) } return data, nil }