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" "github.com/spf13/cast" "gorm.io/gorm" ) // @provider type content struct{} func (s *content) List(ctx context.Context, filter *content_dto.ContentListFilter) (*requests.Pager, error) { tbl, q := models.ContentQuery.QueryContext(ctx) // Filters q = q.Where(tbl.Status.Eq(consts.ContentStatusPublished)) if filter.Keyword != nil && *filter.Keyword != "" { keyword := "%" + *filter.Keyword + "%" q = q.Where(tbl.Title.Like(keyword)).Or(tbl.Description.Like(keyword)) } if filter.Genre != nil && *filter.Genre != "" { q = q.Where(tbl.Genre.Eq(*filter.Genre)) } if filter.TenantID != nil && *filter.TenantID != "" { tid := cast.ToInt64(*filter.TenantID) q = q.Where(tbl.TenantID.Eq(tid)) } // 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.PublishedAt.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) } // Convert to DTO data := make([]content_dto.ContentItem, len(list)) for i, item := range list { data[i] = s.toContentItemDTO(item) } return &requests.Pager{ Pagination: filter.Pagination, Total: total, Items: data, }, nil } func (s *content) Get(ctx context.Context, userID int64, id string) (*content_dto.ContentDetail, error) { cid := cast.ToInt64(id) // Increment Views _, _ = models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(cid)).UpdateSimple(models.ContentQuery.Views.Add(1)) _, q := models.ContentQuery.QueryContext(ctx) var item models.Content err := q.UnderlyingDB(). Preload("Author"). Preload("ContentAssets", func(db *gorm.DB) *gorm.DB { return db.Order("sort ASC") }). Preload("ContentAssets.Asset"). Where("id = ?", cid). First(&item).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.ErrRecordNotFound } return nil, errorx.ErrDatabaseError.WithCause(err) } // 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(cid), models.UserContentActionQuery.Type.Eq("like")). Exists() isFavorited, _ = models.UserContentActionQuery.WithContext(ctx). Where(models.UserContentActionQuery.UserID.Eq(uid), models.UserContentActionQuery.ContentID.Eq(cid), 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(cid), 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) } } } detail := &content_dto.ContentDetail{ ContentItem: s.toContentItemDTO(&item), Description: item.Description, Body: item.Body, MediaUrls: s.toMediaURLs(accessibleAssets), 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, userID int64, id string, page int) (*requests.Pager, error) { cid := cast.ToInt64(id) tbl, q := models.CommentQuery.QueryContext(ctx) q = q.Where(tbl.ContentID.Eq(cid)).Preload(tbl.User) 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: cast.ToString(v.ID), Content: v.Content, UserID: cast.ToString(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: cast.ToString(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, userID int64, id string, form *content_dto.CommentCreateForm) error { if userID == 0 { return errorx.ErrUnauthorized } uid := userID cid := cast.ToInt64(id) c, err := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(cid)).First() if err != nil { return errorx.ErrRecordNotFound } comment := &models.Comment{ TenantID: c.TenantID, UserID: uid, ContentID: cid, Content: form.Content, ReplyTo: cast.ToInt64(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, userID int64, id string) error { if userID == 0 { return errorx.ErrUnauthorized } uid := userID cmid := cast.ToInt64(id) // Fetch comment for author cm, err := models.CommentQuery.WithContext(ctx).Where(models.CommentQuery.ID.Eq(cmid)).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(cmid), tx.UserCommentAction.Type.Eq("like")). Exists() if exists { return nil } action := &models.UserCommentAction{UserID: uid, CommentID: cmid, Type: "like"} if err := tx.UserCommentAction.WithContext(ctx).Create(action); err != nil { return err } _, err := tx.Comment.WithContext(ctx).Where(tx.Comment.ID.Eq(cmid)).UpdateSimple(tx.Comment.Likes.Add(1)) return err }) if err != nil { return err } if Notification != nil { _ = Notification.Send(ctx, cm.UserID, "interaction", "评论点赞", "有人点赞了您的评论") } return nil } func (s *content) GetLibrary(ctx context.Context, userID int64) ([]user_dto.ContentItem, error) { if userID == 0 { return nil, errorx.ErrUnauthorized } uid := userID tbl, q := models.ContentAccessQuery.QueryContext(ctx) accessList, err := q.Where(tbl.UserID.Eq(uid), tbl.Status.Eq(consts.ContentAccessStatusActive)).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 err = cq.Where(ctbl.ID.In(contentIDs...)). 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) dto.IsPurchased = true data = append(data, dto) } return data, nil } func (s *content) GetFavorites(ctx context.Context, userID int64) ([]user_dto.ContentItem, error) { return s.getInteractList(ctx, userID, "favorite") } func (s *content) AddFavorite(ctx context.Context, userID int64, contentId string) error { return s.addInteract(ctx, userID, contentId, "favorite") } func (s *content) RemoveFavorite(ctx context.Context, userID int64, contentId string) error { return s.removeInteract(ctx, userID, contentId, "favorite") } func (s *content) GetLikes(ctx context.Context, userID int64) ([]user_dto.ContentItem, error) { return s.getInteractList(ctx, userID, "like") } func (s *content) AddLike(ctx context.Context, userID int64, contentId string) error { return s.addInteract(ctx, userID, contentId, "like") } func (s *content) RemoveLike(ctx context.Context, userID int64, contentId string) error { return s.removeInteract(ctx, userID, contentId, "like") } func (s *content) ListTopics(ctx context.Context) ([]content_dto.Topic, error) { var results []struct { Genre string Count int } err := models.ContentQuery.WithContext(ctx).UnderlyingDB(). 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 models.ContentQuery.WithContext(ctx). Where(models.ContentQuery.Genre.Eq(r.Genre), models.ContentQuery.Status.Eq(consts.ContentStatusPublished)). 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: cast.ToString(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) content_dto.ContentItem { dto := content_dto.ContentItem{ ID: cast.ToString(item.ID), Title: item.Title, Genre: item.Genre, AuthorID: cast.ToString(item.UserID), Views: int(item.Views), Likes: int(item.Likes), } 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, userID int64, contentId, typ string) error { if userID == 0 { return errorx.ErrUnauthorized } uid := userID cid := cast.ToInt64(contentId) // Fetch content for author c, err := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(cid)).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(cid), tx.UserContentAction.Type.Eq(typ)). Exists() if exists { return nil } action := &models.UserContentAction{UserID: uid, ContentID: cid, Type: typ} if err := tx.UserContentAction.WithContext(ctx).Create(action); err != nil { return err } if typ == "like" { _, err := tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(cid)).UpdateSimple(tx.Content.Likes.Add(1)) return err } return nil }) if err != nil { return err } if Notification != nil { actionName := "互动" if typ == "like" { actionName = "点赞" } else if typ == "favorite" { actionName = "收藏" } _ = Notification.Send(ctx, c.UserID, "interaction", "新的"+actionName, "有人"+actionName+"了您的作品: "+c.Title) } return nil } func (s *content) removeInteract(ctx context.Context, userID int64, contentId, typ string) error { if userID == 0 { return errorx.ErrUnauthorized } uid := userID cid := cast.ToInt64(contentId) 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(cid), tx.UserContentAction.Type.Eq(typ)). Delete() if err != nil { return err } if res.RowsAffected == 0 { return nil } if typ == "like" { _, err := tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(cid)).UpdateSimple(tx.Content.Likes.Sub(1)) return err } return nil }) } func (s *content) getInteractList(ctx context.Context, 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 err = cq.Where(ctbl.ID.In(contentIDs...)). 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)) } return data, nil }