package playletser import ( "context" "crypto/sha256" "encoding/json" "fmt" "sort" "strings" "sync" "time" "videoweb/app/api/proto" "videoweb/app/service/configser" "videoweb/app/service/moreser" "videoweb/app/service/reportser" "videoweb/app/service/slugser" "videoweb/app/service/translationser" "videoweb/app/service/videoser" "videoweb/pub/common/enum/redisconst" "videoweb/pub/common/log" "videoweb/pub/common/netutil" "videoweb/pub/common/response" "videoweb/pub/common/srvdef" "videoweb/pub/common/useragent" "videoweb/pub/common/utils" "videoweb/pub/models" "videoweb/pub/models/announcementmod" "videoweb/pub/models/contenttagmod" "videoweb/pub/models/playletchaptermod" "videoweb/pub/models/playletfavoritemod" "videoweb/pub/models/playlethomeconfigmod" "videoweb/pub/models/playletlikemod" "videoweb/pub/models/playletmod" "videoweb/pub/models/playletthememod" "videoweb/pub/models/videocategorymod" "videoweb/pub/models/videoinformationmod" "videoweb/pub/pubdef" "videoweb/pub/redis" "videoweb/pub/storage/proxy" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo/options" "github.com/gin-gonic/gin" ) const ( playletGuessMinPageSize int64 = 8 playletGuessMaxPageSize int64 = 100 playletRecommendDefaultPageSize int64 = 20 playletRecommendMaxPageSize int64 = 100 playletRecommendAdInterval int64 = 8 playletListAdInterval int64 = 4 playletContentPlayletAdInterval int64 = 4 playletContentVideoAdInterval int64 = 5 homePlayletListLimit int64 = 12 playletDetailHotLimit int64 = 11 ) // PlayletGuessList 分页查询猜你喜欢短剧,按观看量降序返回上架短剧 func PlayletGuessList(ctx context.Context, req *proto.PlayletGuessListReq) (*proto.PlayletGuessListResp, *srvdef.SrvErr) { if req == nil || req.Id < 0 { return nil, srvdef.New(srvdef.ErrParamError, "短剧ID无效") } pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, playletGuessMinPageSize, playletGuessMaxPageSize) if pageSize < playletGuessMinPageSize { pageSize = playletGuessMinPageSize } filter := bson.M{ playletmod.FN.Playlet.Status: playletmod.StatusUp, } if req.Id > 0 { filter[playletmod.FN.Playlet.Id] = bson.M{"$ne": req.Id} } total, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletGuessListResp{BasePageResp: response.BasePageResp{ TotalCount: total, PageNum: int(pageNum), PageSize: int(pageSize), List: make([]*proto.PlayletRecommendListItem, 0), }} if total == 0 || pageNum > (total-1)/pageSize+1 { return resp, nil } list, err := findPlayletRecommendPage(ctx, filter, bson.D{ {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, }, (pageNum-1)*pageSize, pageSize) if err != nil { return nil, err } tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, list) if err != nil { return nil, err } cdn := configser.ImageCdnFromCtx(ctx) items := make([]*proto.PlayletRecommendListItem, 0, len(list)) for _, item := range list { if item != nil { items = append(items, playletToRecommendListItem(item, cdn, tagNames, themeNames)) } } resp.List = items return resp, nil } // playletRankAggItem 短剧排行榜聚合结果,只保留榜单展示需要的轻量字段 type playletRankAggItem struct { playletmod.Playlet `bson:",inline"` RankScore int64 `bson:"rankScore"` } type playletRankCount struct { Total int64 `bson:"total"` } // PlayletThemeList 查询全部已启用短剧题材 func PlayletThemeList(ctx context.Context) (*proto.PlayletThemeListResp, *srvdef.SrvErr) { list, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{playletthememod.FN.PlayletTheme.Status: true}, options.Find(). SetProjection(bson.D{ {Key: playletthememod.FN.PlayletTheme.Id, Value: 1}, {Key: playletthememod.FN.PlayletTheme.Name, Value: 1}, {Key: playletthememod.FN.PlayletTheme.Slug, Value: 1}, {Key: playletthememod.FN.PlayletTheme.Icon, Value: 1}, {Key: playletthememod.FN.PlayletTheme.Rank, Value: 1}, {Key: playletthememod.FN.PlayletTheme.SeoInfo.Title, Value: 1}, {Key: playletthememod.FN.PlayletTheme.SeoInfo.Description, Value: 1}, {Key: playletthememod.FN.PlayletTheme.SeoInfo.Keyworld, Value: 1}, }). SetSort(bson.D{ {Key: playletthememod.FN.PlayletTheme.Rank, Value: -1}, {Key: playletthememod.FN.PlayletTheme.Id, Value: -1}, }), ) if err != nil { return nil, err } cdn := configser.ImageCdnFromCtx(ctx) resp := &proto.PlayletThemeListResp{List: make([]proto.PlayletThemeItem, 0, len(list))} for _, item := range list { if item == nil { continue } resp.List = append(resp.List, proto.PlayletThemeItem{ Id: item.Id, Name: item.Name, Slug: item.Slug, Icon: netutil.MergeURL(cdn, item.Icon), Rank: item.Rank, SeoInfo: item.SeoInfo, }) } return resp, nil } var appPlayletRecommendProjection = bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, {Key: playletmod.FN.Playlet.Status, Value: 1}, {Key: playletmod.FN.Playlet.IsRecommend, Value: 1}, {Key: playletmod.FN.Playlet.IsFeatured, Value: 1}, {Key: playletmod.FN.Playlet.IsOriginal, Value: 1}, {Key: playletmod.FN.Playlet.ShowOriginal, Value: 1}, {Key: playletmod.FN.Playlet.MediaCenterId, Value: 1}, {Key: playletmod.FN.Playlet.OnlineAt, Value: 1}, {Key: playletmod.FN.Playlet.MediaCreatedAt, Value: 1}, {Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: 1}, {Key: playletmod.FN.Playlet.Title, Value: 1}, {Key: playletmod.FN.Playlet.Slug, Value: 1}, {Key: playletmod.FN.Playlet.Desc, Value: 1}, {Key: playletmod.FN.Playlet.Anchor, Value: 1}, {Key: playletmod.FN.Playlet.Cover, Value: 1}, {Key: playletmod.FN.Playlet.CompressedCover, Value: 1}, {Key: playletmod.FN.Playlet.Preview, Value: 1}, {Key: playletmod.FN.Playlet.CategoryIds, Value: 1}, {Key: playletmod.FN.Playlet.TagIds, Value: 1}, {Key: playletmod.FN.Playlet.ThemeIds, Value: 1}, {Key: playletmod.FN.Playlet.ContentType, Value: 1}, {Key: playletmod.FN.Playlet.PayType, Value: 1}, {Key: playletmod.FN.Playlet.SingleCoins, Value: 1}, {Key: playletmod.FN.Playlet.TotalEpisode, Value: 1}, {Key: playletmod.FN.Playlet.FreeEpisodeCount, Value: 1}, {Key: playletmod.FN.Playlet.UpdateStatus, Value: 1}, {Key: playletmod.FN.Playlet.CountBrowse, Value: 1}, {Key: playletmod.FN.Playlet.VirtualCountBrowse, Value: 1}, {Key: playletmod.FN.Playlet.HeatValue, Value: 1}, {Key: playletmod.FN.Playlet.VirtualHeatValue, Value: 1}, {Key: playletmod.FN.Playlet.CountCollect, Value: 1}, {Key: playletmod.FN.Playlet.CountLike, Value: 1}, {Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1}, {Key: playletmod.FN.Playlet.SeoInfo.Title, Value: 1}, {Key: playletmod.FN.Playlet.SeoInfo.Description, Value: 1}, {Key: playletmod.FN.Playlet.SeoInfo.Keyworld, Value: 1}, {Key: playletmod.FN.Playlet.CreatedAt, Value: 1}, {Key: playletmod.FN.Playlet.UpdatedAt, Value: 1}, } // playletDetailProjection 短剧详情投影,额外返回详情页需要的评论数量 func playletDetailProjection() bson.D { projection := make(bson.D, 0, len(appPlayletRecommendProjection)+1) projection = append(projection, appPlayletRecommendProjection...) projection = append(projection, bson.E{Key: playletmod.FN.Playlet.CountComment, Value: 1}) return projection } // PlayletRecommendList 分页查询 app 端短剧推荐列表,并按固定间隔穿插列表广告 func PlayletRecommendList(ctx context.Context, req *proto.PlayletRecommendListReq) (*proto.PlayletRecommendListResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } categoryId, err := slugser.PlayletCategoryID(ctx, req.CategoryId, req.CategorySlug, false) if err != nil { return nil, err } tagId, err := slugser.ContentTagID(ctx, req.TagId, req.TagSlug, false) if err != nil { return nil, err } req.CategoryId = categoryId req.TagId = tagId pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, playletRecommendDefaultPageSize, playletRecommendMaxPageSize) filter, sort := playletRecommendFilterAndSort(req.TagId, req.CategoryId, req.SortList) config := configser.GetRequestConfig(ctx) ads := configser.GetAdvitise().PostList totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } if len(ads) == 0 { return playletRecommendListWithoutAds(ctx, filter, sort, req.CategoryId, pageNum, pageSize, totalCount, config.ImgCdn) } adsTotalCount := moreser.InsertedAdsCount(totalCount, int64(len(ads)), playletRecommendAdInterval) mixedTotalCount := totalCount + adsTotalCount start := (pageNum - 1) * pageSize if start >= mixedTotalCount || totalCount == 0 { resp := emptyPlayletRecommendListPage(pageNum, pageSize) resp.TotalCount = mixedTotalCount return resp, nil } end := start + pageSize if end > mixedTotalCount { end = mixedTotalCount } contentStart, contentLimit := moreser.ContentRange(start, end, totalCount, adsTotalCount, playletRecommendAdInterval) list := playletmod.PlayletList{} if contentLimit > 0 { list, err = findPlayletRecommendPage(ctx, filter, sort, contentStart, contentLimit, req.CategoryId) if err != nil { return nil, err } } tagNameMap, themeNameMap, err := buildPlayletTagThemeNameMaps(ctx, list) if err != nil { return nil, err } items := buildPlayletRecommendMixedList(list, ads, config.ImgCdn, tagNameMap, themeNameMap, start, end, contentStart, adsTotalCount) return &proto.PlayletRecommendListResp{BasePageResp: response.BasePageResp{ TotalCount: mixedTotalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: items, }}, nil } // PlayletList 分页查询已上架短剧,每四部短剧后穿插一条广告 func PlayletList(ctx context.Context, req *proto.PlayletListReq) (*proto.PlayletListResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } if req.SortType != 0 && req.SortType != 1 && req.SortType != 2 { return nil, srvdef.New(srvdef.ErrParamError, "排序方式无效", "") } filter := playletListFilter() if slug := strings.TrimSpace(req.ThemeSlug); slug != "" || req.ThemeId > 0 { themeFilter := bson.M{playletthememod.FN.PlayletTheme.Status: true} if slug != "" { themeFilter[playletthememod.FN.PlayletTheme.Slug] = slug } else { themeFilter[playletthememod.FN.PlayletTheme.Id] = req.ThemeId } theme, err := playletthememod.FindOnePlayletTheme(ctx, themeFilter) if err != nil { return nil, err } filter[playletmod.FN.Playlet.ThemeIds] = theme.Id } pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, playletRecommendDefaultPageSize, playletRecommendMaxPageSize) playletCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } advitise := configser.GetAdvitise() ads := advitise.PostList adCount := playletListInsertedAdsCount(playletCount, len(ads)) mixedCount := playletCount + adCount resp := &proto.PlayletListResp{ BasePageResp: response.BasePageResp{ TotalCount: mixedCount, PageNum: int(pageNum), PageSize: int(pageSize), List: make([]*proto.PlayletRecommendListItem, 0), }, Advitise: advitise, } start := (pageNum - 1) * pageSize if start >= mixedCount { return resp, nil } end := start + pageSize if end > mixedCount { end = mixedCount } contentStart, contentLimit := moreser.ContentRange(start, end, playletCount, adCount, playletListAdInterval) list := make(playletmod.PlayletList, 0) if contentLimit > 0 { list, err = findPlayletRecommendPage(ctx, filter, playletListSort(req.SortType), contentStart, contentLimit) if err != nil { return nil, err } } tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, list) if err != nil { return nil, err } resp.List = buildPlayletListMixedPage(list, ads, configser.GetRequestConfig(ctx).ImgCdn, tagNames, themeNames, start, end, contentStart, adCount) return resp, nil } // ContentList 分页查询上架短剧或视频,短剧每四条、视频每五条穿插一条广告 func ContentList(ctx context.Context, req *proto.PlayletContentListReq) (*proto.PlayletContentListResp, *srvdef.SrvErr) { if req == nil || (req.DataType != 1 && req.DataType != 2) { return nil, srvdef.New(srvdef.ErrParamError, "数据类型无效", "") } pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, playletRecommendDefaultPageSize, playletRecommendMaxPageSize) var ( contentCount int64 err *srvdef.SrvErr ) if req.DataType == 1 { contentCount, err = playletmod.CountPlaylet(ctx, bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp}) } else { contentCount, err = videoinformationmod.CountVideoInformations(ctx, bson.M{ videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, }) } if err != nil { return nil, err } advitise := configser.GetAdvitise() ads := advitise.PostList interval := playletContentAdInterval(req.DataType) adCount := playletContentInsertedAdsCount(contentCount, len(ads), interval) mixedCount := contentCount + adCount resp := &proto.PlayletContentListResp{ BasePageResp: response.BasePageResp{ TotalCount: mixedCount, PageNum: int(pageNum), PageSize: int(pageSize), List: make([]*proto.PlayletContentListItem, 0), }, Advitise: advitise, } start := (pageNum - 1) * pageSize if start >= mixedCount { return resp, nil } end := start + pageSize if end > mixedCount { end = mixedCount } contentStart, contentLimit := moreser.ContentRange(start, end, contentCount, adCount, interval) imgCdn := configser.GetRequestConfig(ctx).ImgCdn items := make([]*proto.PlayletContentListItem, 0, contentLimit) if contentLimit > 0 { items, err = findPlayletContentPage(ctx, req.DataType, contentStart, contentLimit, imgCdn) if err != nil { return nil, err } } resp.List = buildPlayletContentMixedPage(items, ads, imgCdn, start, end, contentStart, adCount, interval) return resp, nil } // playletContentAdInterval 根据数据类型返回广告插入间隔 func playletContentAdInterval(dataType int64) int64 { if dataType == 2 { return playletContentVideoAdInterval } return playletContentPlayletAdInterval } // playletContentInsertedAdsCount 只在后续仍有内容时插入广告,素材不足时循环使用 func playletContentInsertedAdsCount(contentCount int64, adsCount int, interval int64) int64 { if contentCount <= 1 || adsCount == 0 { return 0 } return (contentCount - 1) / interval } // findPlayletContentPage 按数据类型查询当前页需要的内容并转换为统一列表项 func findPlayletContentPage(ctx context.Context, dataType, skip, limit int64, imgCdn string) ([]*proto.PlayletContentListItem, *srvdef.SrvErr) { items := make([]*proto.PlayletContentListItem, 0, limit) if dataType == 1 { list, err := findPlayletRecommendPage(ctx, bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp}, playletContentPlayletSort(), skip, limit) if err != nil { return nil, err } tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, list) if err != nil { return nil, err } for _, item := range list { data := playletToAppData(item, imgCdn, tagNames, themeNames) items = append(items, &proto.PlayletContentListItem{Type: 0, Playlet: &data}) } return items, nil } list := make(videoinformationmod.VideoInformationList, 0) if err := videoinformationmod.AggregateVideoInformations(ctx, &list, playletContentVideoPipeline(skip, limit)); err != nil { return nil, err } for _, item := range list { data := playletContentVideoInfo(item, imgCdn) items = append(items, &proto.PlayletContentListItem{Type: 0, Video: &data}) } return items, nil } // playletContentPlayletSort 先按短剧累计浏览数排序,再按热度和 ID 稳定排序 func playletContentPlayletSort() bson.D { return bson.D{ {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } } // playletContentVideoPipeline 按视频展示观看数分页,展示观看数为真实数与后台配置数之和 func playletContentVideoPipeline(skip, limit int64) []bson.M { return []bson.M{ {"$match": bson.M{videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK}}, {"$addFields": bson.M{"watchScore": bson.M{"$add": []interface{}{ bson.M{"$ifNull": []interface{}{"$" + videoinformationmod.FN.VideoInformation.Real.WatchTimes, 0}}, bson.M{"$ifNull": []interface{}{"$" + videoinformationmod.FN.VideoInformation.Fake.WatchTimes, 0}}, }}}}, {"$sort": bson.D{ {Key: "watchScore", Value: -1}, {Key: videoinformationmod.FN.VideoInformation.UpdatedAt, Value: -1}, {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, }}, {"$skip": skip}, {"$limit": limit}, {"$project": playletContentVideoProjection()}, } } // playletContentVideoProjection 只读取视频卡片需要的字段 func playletContentVideoProjection() bson.M { return bson.M{ videoinformationmod.FN.VideoInformation.Id: 1, videoinformationmod.FN.VideoInformation.Title: 1, videoinformationmod.FN.VideoInformation.Slug: 1, videoinformationmod.FN.VideoInformation.Keyworld: 1, videoinformationmod.FN.VideoInformation.Desc: 1, videoinformationmod.FN.VideoInformation.AddedTime: 1, videoinformationmod.FN.VideoInformation.CoverImg: 1, videoinformationmod.FN.VideoInformation.Tags: 1, videoinformationmod.FN.VideoInformation.Real.WatchTimes: 1, videoinformationmod.FN.VideoInformation.Fake.WatchTimes: 1, videoinformationmod.FN.VideoInformation.Real.Likes: 1, videoinformationmod.FN.VideoInformation.Fake.Likes: 1, } } // playletContentVideoInfo 将视频模型转换为列表卡片 func playletContentVideoInfo(item *videoinformationmod.VideoInformation, imgCdn string) proto.VideoInfo { tagNames, tagSlugs := configser.VideoTagNamesAndSlugs(item.Tags) return proto.VideoInfo{ContentData: proto.ContentData{ Id: item.Id, Title: item.Title, Slug: item.Slug, AddedTime: item.AddedTime, CoverImg: netutil.MergeURL(imgCdn, item.CoverImg), Tags: item.Tags, TagsName: tagNames, TagSlugs: tagSlugs, WatchNum: item.Real.WatchTimes + item.Fake.WatchTimes, LikeNum: item.Real.Likes + item.Fake.Likes, SeoInfo: pubdef.SeoValue{Title: item.Title, Keyworld: append([]string{}, item.Keyworld...), Description: item.Desc}, }} } // buildPlayletContentMixedPage 按全局位置组装内容与广告并保持跨页间隔一致 func buildPlayletContentMixedPage(content []*proto.PlayletContentListItem, ads []proxy.AdvitiseInfo, imgCdn string, start, end, contentStart, adCount, interval int64) []*proto.PlayletContentListItem { items := make([]*proto.PlayletContentListItem, 0, end-start) for pos := start; pos < end; pos++ { adIndex := moreser.AdIndexAtPositionWithoutTotal(pos, adCount, interval) if adIndex >= 0 && len(ads) > 0 { ad := ads[adIndex%int64(len(ads))] ad.Images = netutil.MergeURL(imgCdn, ad.Images) items = append(items, &proto.PlayletContentListItem{Type: 2, Advertise: &ad}) continue } contentIndex := moreser.ContentIndexAtPositionWithoutTotal(pos, adCount, interval) - contentStart if contentIndex >= 0 && contentIndex < int64(len(content)) { items = append(items, content[contentIndex]) } } return items } // playletListFilter 只查询已上架短剧 func playletListFilter() bson.M { return bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp} } // playletListInsertedAdsCount 只在仍有后续短剧时插入广告,广告素材不足时循环使用 func playletListInsertedAdsCount(playletCount int64, adsCount int) int64 { if playletCount <= 1 || adsCount == 0 { return 0 } return (playletCount - 1) / playletListAdInterval } // playletListSort 根据列表排序方式生成稳定的短剧顺序 func playletListSort(sortType int64) bson.D { switch sortType { case 2: return bson.D{{Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}} default: return bson.D{{Key: playletmod.FN.Playlet.HeatValue, Value: -1}, {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}} } } // buildPlayletListMixedPage 根据全局位置组装当前页,避免跨页后重新计算广告间隔 func buildPlayletListMixedPage(list playletmod.PlayletList, ads []proxy.AdvitiseInfo, imgCdn string, tagNames contenttagmod.ContentTagMap, themeNames map[int64]string, start, end, contentStart, adCount int64) []*proto.PlayletRecommendListItem { items := make([]*proto.PlayletRecommendListItem, 0, end-start) contentByIndex := make(map[int64]*playletmod.Playlet, len(list)) for i, item := range list { contentByIndex[contentStart+int64(i)] = item } for pos := start; pos < end; pos++ { adIndex := moreser.AdIndexAtPositionWithoutTotal(pos, adCount, playletListAdInterval) if adIndex >= 0 && len(ads) > 0 { items = append(items, playletAdToRecommendListItem(ads[adIndex%int64(len(ads))], imgCdn)) continue } contentIndex := moreser.ContentIndexAtPositionWithoutTotal(pos, adCount, playletListAdInterval) if item := contentByIndex[contentIndex]; item != nil { items = append(items, playletToRecommendListItem(item, imgCdn, tagNames, themeNames)) } } return items } // PlayletTagList 根据统一标签ID分页查询已上架短剧 func PlayletTagList(ctx context.Context, req *proto.ContentTagPageReq) (*proto.PlayletRecommendListResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "") } tagId, err := slugser.ContentTagID(ctx, req.Id, req.Slug, true) if err != nil { return nil, err } req.Id = tagId pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, playletRecommendDefaultPageSize, playletRecommendMaxPageSize) ok, err := configser.HasEnabledContentTagBind(ctx, req.Id, contenttagmod.ContentTagTypePlaylet) if err != nil { return nil, err } if !ok { return emptyPlayletRecommendListPage(pageNum, pageSize), nil } filter := bson.M{ playletmod.FN.Playlet.Status: playletmod.StatusUp, playletmod.FN.Playlet.TagIds: req.Id, } sort := bson.D{ {Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } config := configser.GetRequestConfig(ctx) totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } return playletRecommendListWithoutAds(ctx, filter, sort, 0, pageNum, pageSize, totalCount, config.ImgCdn) } // QueryPlayletInfo 查询短剧详情、剧集列表和点赞状态 func QueryPlayletInfo(ctx context.Context, userId int64, ip, ua string, req *proto.QueryPlayletInfoReq) (*proto.QueryPlayletInfoResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } playletId, err := slugser.PlayletID(ctx, req.Id, req.Slug, true) if err != nil { return nil, err } req.Id = playletId config := configser.GetRequestConfig(ctx) playletInfo, err := playletmod.FindOnePlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: req.Id, playletmod.FN.Playlet.Status: playletmod.StatusUp, }, options.FindOne().SetProjection(playletDetailProjection())) if err != nil { return nil, err } go incrementPlayletWatchCount(playletInfo) chapterList, isLike, err := queryPlayletDetailExtraData(ctx, userId, playletInfo, ip, ua) if err != nil { return nil, err } isFavorite, err := queryPlayletIsFavorite(ctx, userId, playletInfo.Id) if err != nil { return nil, err } tagNameMap, themeNameMap, err := buildPlayletTagThemeNameMaps(ctx, playletmod.PlayletList{playletInfo}) if err != nil { return nil, err } if err := applyOfficialChapterAccess(ctx, playletInfo, chapterList, ip); err != nil { return nil, err } return &proto.QueryPlayletInfoResp{ FreeEpisodeCount: playletInfo.EffectiveFreeEpisodeCount(), Path: playletRequestPath(ctx), Translations: translationser.GetTranslationData(), Config: config, BottonTranslations: translationser.GetTranslationBottomList(), Advitise: configser.GetAdvitise(), Announcement: configser.GetAnnouncement(), PlayletInfo: playletDetailInfoData(playletInfo, config.ImgCdn, tagNameMap, themeNameMap, isLike, isFavorite), ChapterList: chapterList, IsLike: isLike, IsFavorite: isFavorite, }, nil } // QueryPlayletChapterInfo 查询短剧剧集详情、所属短剧、剧集列表、热门短剧列表和点赞状态 func QueryPlayletChapterInfo(ctx context.Context, userId int64, ip, ua string, req *proto.QueryPlayletChapterInfoReq) (*proto.QueryPlayletChapterInfoResp, *srvdef.SrvErr) { if req == nil || req.Id <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "", "") } config := configser.GetRequestConfig(ctx) chapterInfo, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{ playletchaptermod.FN.PlayletChapter.Id: req.Id, }, options.FindOne().SetProjection(playletChapterProjection())) if err != nil { return nil, err } playletInfo, err := playletmod.FindOnePlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: chapterInfo.PlayletId, playletmod.FN.Playlet.Status: playletmod.StatusUp, }, options.FindOne().SetProjection(appPlayletRecommendProjection)) if err != nil { return nil, err } if !playletInfo.IsOfficialEpisodeOpen(chapterInfo.CurrentEpisode) { return nil, srvdef.New(srvdef.ErrNoData, "本集暂未在官网开放", "") } if err = ensureOriginalChapterWatermarkReady(playletInfo, chapterInfo); err != nil { return nil, err } go incrementPlayletWatchCount(playletInfo) go incrementPlayletChapterWatchCount(chapterInfo.Id) hotPlayletList, chapterList, isLike, err := queryPlayletChapterDetailExtraData(ctx, userId, playletInfo, ip, ua) if err != nil { return nil, err } isFavorite, err := queryPlayletIsFavorite(ctx, userId, playletInfo.Id) if err != nil { return nil, err } allList := make(playletmod.PlayletList, 0, len(hotPlayletList)+1) allList = append(allList, playletInfo) allList = append(allList, hotPlayletList...) tagNameMap, themeNameMap, err := buildPlayletTagThemeNameMaps(ctx, allList) if err != nil { return nil, err } chapterData := make([]proto.PlayletChapterInfo, 0, len(chapterList)+1) chapterData = append(chapterData, chapterList...) chapterData = append(chapterData, playletChapterToInfo(chapterInfo, ip, ua, isOriginalPlaylet(playletInfo))) if err := applyOfficialChapterAccess(ctx, playletInfo, chapterData, ip); err != nil { return nil, err } return &proto.QueryPlayletChapterInfoResp{ FreeEpisodeCount: playletInfo.EffectiveFreeEpisodeCount(), Path: playletRequestPath(ctx), Translations: translationser.GetTranslationData(), Config: config, BottonTranslations: translationser.GetTranslationBottomList(), Advitise: configser.GetAdvitise(), Announcement: configser.GetAnnouncement(), ChapterInfo: chapterData[len(chapterData)-1], PlayletInfo: playletToAppData(playletInfo, config.ImgCdn, tagNameMap, themeNameMap), ChapterList: chapterData[:len(chapterData)-1], HotPlayletList: playletListToAppData(hotPlayletList, config.ImgCdn, tagNameMap, themeNameMap), IsLike: isLike, IsFavorite: isFavorite, }, nil } func applyOfficialChapterAccess(ctx context.Context, playlet *playletmod.Playlet, chapters []proto.PlayletChapterInfo, ip string) *srvdef.SrvErr { if len(chapters) == 0 { return nil } ids := make([]int64, 0, len(chapters)) for i := range chapters { ids = append(ids, chapters[i].Id) } raw, err := playletchaptermod.FindManyPlayletChapter(ctx, bson.M{ playletchaptermod.FN.PlayletChapter.Id: bson.M{"$in": ids}, playletchaptermod.FN.PlayletChapter.PlayletId: playlet.Id, }) if err != nil { return err } byID := make(map[int64]*playletchaptermod.PlayletChapter, len(raw)) for _, chapter := range raw { byID[chapter.Id] = chapter } for i := range chapters { chapter := byID[chapters[i].Id] open := chapter != nil && playlet.IsOfficialEpisodeOpen(chapter.CurrentEpisode) chapters[i].IsOfficialOpen = &open chapters[i].VideoUrl = "" if !open { continue } url, useNewKey := chapterPlaySource(chapter, isOriginalPlaylet(playlet)) if url != "" { chapters[i].VideoUrl = videoser.GetM3U8OfficialParam(chapter.Id, playlet.Id, url, ip, useNewKey) } } return nil } // LikePlaylet 当前登录用户点赞或取消点赞指定短剧 func LikePlaylet(ctx context.Context, userId int64, ip string, ua useragent.UA, req *proto.LikePlayletReq) (*proto.LikePlayletResp, *srvdef.SrvErr) { if req == nil || userId <= 0 || req.Id <= 0 || req.IsLike == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } playletInfo, err := playletmod.FindOnePlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: req.Id, playletmod.FN.Playlet.Status: playletmod.StatusUp, }, options.FindOne().SetProjection(bson.M{ playletmod.FN.Playlet.Id: 1, playletmod.FN.Playlet.Title: 1, playletmod.FN.Playlet.Slug: 1, playletmod.FN.Playlet.ThemeIds: 1, playletmod.FN.Playlet.TagIds: 1, playletmod.FN.Playlet.TotalEpisode: 1, playletmod.FN.Playlet.CountLike: 1, playletmod.FN.Playlet.VirtualCountLike: 1, })) if err != nil { return nil, err } if *req.IsLike { created, err := playletlikemod.UpsertOnePlayletLike(ctx, userId, req.Id, time.Now()) if err != nil { return nil, err } if created { playletInfo.CountLike++ if err := playletmod.UpdateOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}, bson.M{"$inc": bson.M{playletmod.FN.Playlet.CountLike: 1}}); err != nil { return nil, err } if err := reportser.ReportPlayletLike(userId, ua, ip, playletInfo, 1); err != nil { log.Warn("ReportPlayletLike failed", log.Any("playletId", req.Id), log.Any("userId", userId), log.E(err)) } } } else { deleted, err := playletlikemod.DeleteOnePlayletLike(ctx, bson.M{ playletlikemod.FN.PlayletLike.UserID: userId, playletlikemod.FN.PlayletLike.PlayletID: req.Id, }) if err != nil { return nil, err } if deleted { if playletInfo.CountLike > 0 { playletInfo.CountLike-- if err := playletmod.UpdateOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}, bson.M{"$inc": bson.M{playletmod.FN.Playlet.CountLike: -1}}); err != nil { return nil, err } } if err := reportser.ReportPlayletLike(userId, ua, ip, playletInfo, 2); err != nil { log.Warn("ReportPlayletLike failed", log.Any("playletId", req.Id), log.Any("userId", userId), log.E(err)) } } } return &proto.LikePlayletResp{ Id: req.Id, IsLike: *req.IsLike, LikeNum: playletInfo.CountLike, }, nil } // QueryPlayletRank 分页查询短剧排行榜,读取独立的 10 分钟内存缓存。 // 点赞榜按累计点赞量、周/月/年榜按对应周期观看量取前 50;不足时随机补位。分页 pageSize 不是 50 时强制为 50。 func QueryPlayletRank(ctx context.Context, req *proto.PlayletRankReq) (*proto.PlayletRankResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } period := proto.PlayletRankPeriod(req.RankPeriod) if period < proto.PlayletRankPeriodLike || period > proto.PlayletRankPeriodYear { return nil, srvdef.New(srvdef.ErrParamError, "", "") } pageNum, pageSize := configser.NormalizeRankPage(req.PageNum, req.PageSize) all := configser.GetPlayletRankList(configser.RankPeriod(period), 0) items := configser.SliceRankPage(all, pageNum, pageSize) return &proto.PlayletRankResp{BasePageResp: response.BasePageResp{ TotalCount: int64(len(all)), PageSize: int(pageSize), PageNum: int(pageNum), List: items, }, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()}, nil } // HomePlayletData 查询首页推荐、最新、最热三组短剧数据 func HomePlayletData(ctx context.Context, imgCdn string) (proto.HomePlayletDataResp, *srvdef.SrvErr) { resp := proto.HomePlayletDataResp{ RecommendList: make([]proto.PlayletData, 0), NewestList: make([]proto.PlayletData, 0), HotList: make([]proto.PlayletData, 0), } var ( recommendList playletmod.PlayletList newestList playletmod.PlayletList hotList playletmod.PlayletList wg sync.WaitGroup mu sync.Mutex err1 *srvdef.SrvErr ) setErr := func(err *srvdef.SrvErr) { if err == nil { return } mu.Lock() defer mu.Unlock() if err1 == nil { err1 = err } } queryList := func(sortType proto.PlayletRecommendSort, target *playletmod.PlayletList) { defer wg.Done() filter, sort := playletRecommendFilterAndSort(0, 0, int64(sortType)) list, err := findPlayletRecommendPage(ctx, filter, sort, 0, homePlayletListLimit) if err != nil { setErr(err) return } *target = list } wg.Add(3) go queryList(proto.PlayletRecommendSortRecommend, &recommendList) go queryList(proto.PlayletRecommendSortNewest, &newestList) go queryList(proto.PlayletRecommendSortHot, &hotList) wg.Wait() if err1 != nil { return resp, err1 } allList := make(playletmod.PlayletList, 0, len(recommendList)+len(newestList)+len(hotList)) allList = append(allList, recommendList...) allList = append(allList, newestList...) allList = append(allList, hotList...) tagNameMap, themeNameMap, err := buildPlayletTagThemeNameMaps(ctx, allList) if err != nil { return resp, err } resp.RecommendList = playletListToAppData(recommendList, imgCdn, tagNameMap, themeNameMap) resp.NewestList = playletListToAppData(newestList, imgCdn, tagNameMap, themeNameMap) resp.HotList = playletListToAppData(hotList, imgCdn, tagNameMap, themeNameMap) return resp, nil } // playletRecommendFilterAndSort 根据排序枚举构建短剧查询条件和排序规则 func playletRecommendFilterAndSort(tagId, categoryId, sortList int64) (bson.M, bson.D) { filter := bson.M{ playletmod.FN.Playlet.Status: playletmod.StatusUp, } if tagId > 0 { filter[playletmod.FN.Playlet.TagIds] = tagId } if categoryId > 0 { filter[playletmod.FN.Playlet.CategoryIds] = bson.M{"$in": []int64{categoryId}} } switch proto.PlayletRecommendSort(sortList) { case proto.PlayletRecommendSortNewest: return filter, bson.D{ {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } case proto.PlayletRecommendSortHot: return filter, bson.D{ {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } default: if categoryId <= 0 { filter[playletmod.FN.Playlet.IsRecommend] = true } else { return filter, playletmod.CategoryRankSort() } return filter, bson.D{ {Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, {Key: playletmod.FN.Playlet.CreatedAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } } } // findPlayletRecommendPage 查询当前混排分页需要的短剧数据 func findPlayletRecommendPage(ctx context.Context, filter bson.M, sort bson.D, skip, limit int64, categoryIds ...int64) (playletmod.PlayletList, *srvdef.SrvErr) { if len(categoryIds) > 0 && categoryIds[0] > 0 && len(sort) > 0 && sort[0].Key == "_categoryRank" { return playletmod.FindManyPlayletByCategoryRank(ctx, filter, categoryIds[0], skip, limit, sort, appPlayletRecommendProjection) } computedSort := make(bson.D, len(sort)) copy(computedSort, sort) addFields := bson.M{} for i := range computedSort { switch computedSort[i].Key { case playletmod.FN.Playlet.CountBrowse: computedSort[i].Key = "displayCountBrowse" addFields["displayCountBrowse"] = bson.M{"$add": []interface{}{ bson.M{"$ifNull": []interface{}{"$" + playletmod.FN.Playlet.CountBrowse, 0}}, bson.M{"$ifNull": []interface{}{"$" + playletmod.FN.Playlet.VirtualCountBrowse, 0}}, }} case playletmod.FN.Playlet.HeatValue: computedSort[i].Key = "displayHeatValue" addFields["displayHeatValue"] = bson.M{"$add": []interface{}{ bson.M{"$ifNull": []interface{}{"$" + playletmod.FN.Playlet.HeatValue, 0}}, bson.M{"$ifNull": []interface{}{"$" + playletmod.FN.Playlet.VirtualHeatValue, 0}}, }} } } if len(addFields) > 0 { list := make(playletmod.PlayletList, 0) pipeline := []bson.M{ {"$match": filter}, {"$addFields": addFields}, {"$sort": computedSort}, {"$skip": skip}, {"$limit": limit}, {"$project": appPlayletRecommendProjection}, } if err := playletmod.AggregatePlaylet(ctx, &list, pipeline, options.Aggregate().SetAllowDiskUse(true)); err != nil { return nil, err } return list, nil } return playletmod.FindManyPlaylet(ctx, filter, options.Find(). SetProjection(appPlayletRecommendProjection). SetSort(sort). SetSkip(skip). SetLimit(limit)) } // playletListToAppData 批量转换短剧模型为 app 短剧数据 func playletListToAppData(list playletmod.PlayletList, imgCdn string, tagNameMap contenttagmod.ContentTagMap, themeNameMap map[int64]string) []proto.PlayletData { result := make([]proto.PlayletData, 0, len(list)) for _, item := range list { if item == nil { continue } result = append(result, playletToAppData(item, imgCdn, tagNameMap, themeNameMap)) } return result } // playletRecommendListWithoutAds 无广告时直接按短剧分页查询 func playletRecommendListWithoutAds(ctx context.Context, filter bson.M, sort bson.D, categoryId, pageNum, pageSize, totalCount int64, imgCdn string) (*proto.PlayletRecommendListResp, *srvdef.SrvErr) { list, err := findPlayletRecommendPage(ctx, filter, sort, (pageNum-1)*pageSize, pageSize, categoryId) if err != nil { return nil, err } tagNameMap, themeNameMap, err := buildPlayletTagThemeNameMaps(ctx, list) if err != nil { return nil, err } items := make([]*proto.PlayletRecommendListItem, 0, len(list)) for _, item := range list { if item == nil { continue } items = append(items, playletToRecommendListItem(item, imgCdn, tagNameMap, themeNameMap)) } return &proto.PlayletRecommendListResp{BasePageResp: response.BasePageResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: items, }}, nil } // buildPlayletRecommendMixedList 按混排后的全局位置切出当前页短剧和广告数据 func buildPlayletRecommendMixedList(list playletmod.PlayletList, ads []proxy.AdvitiseInfo, imgCdn string, tagNameMap contenttagmod.ContentTagMap, themeNameMap map[int64]string, start, end, contentStart, adsTotal int64) []*proto.PlayletRecommendListItem { items := make([]*proto.PlayletRecommendListItem, 0, end-start) contentByIndex := make(map[int64]*playletmod.Playlet, len(list)) for i, item := range list { contentByIndex[contentStart+int64(i)] = item } for pos := start; pos < end; pos++ { adIndex := moreser.AdIndexAtPositionWithoutTotal(pos, adsTotal, playletRecommendAdInterval) if adIndex >= 0 && adIndex < adsTotal { items = append(items, playletAdToRecommendListItem(ads[adIndex], imgCdn)) continue } contentIndex := moreser.ContentIndexAtPositionWithoutTotal(pos, adsTotal, playletRecommendAdInterval) if item := contentByIndex[contentIndex]; item != nil { items = append(items, playletToRecommendListItem(item, imgCdn, tagNameMap, themeNameMap)) } } return items } // playletToRecommendListItem 将短剧模型转换为 app 推荐短剧列表项 func playletToRecommendListItem(item *playletmod.Playlet, imgCdn string, tagNameMap contenttagmod.ContentTagMap, themeNameMap map[int64]string) *proto.PlayletRecommendListItem { return &proto.PlayletRecommendListItem{ Type: proto.PlayletRecommendItemTypePlaylet, PlayletData: playletToAppData(item, imgCdn, tagNameMap, themeNameMap), } } // playletAdToRecommendListItem 将广告转换为 app 推荐短剧列表项 func playletAdToRecommendListItem(ad proxy.AdvitiseInfo, imgCdn string) *proto.PlayletRecommendListItem { return &proto.PlayletRecommendListItem{ Type: proto.PlayletRecommendItemTypeAdvertise, AdvertiseId: ad.Id, Link: ad.Link, Location: ad.Location, PlayletData: proto.PlayletData{ Title: ad.Title, Cover: netutil.MergeURL(imgCdn, ad.Images), }, } } // queryPlayletDetailExtraData 并发查询详情页附加数据,缩短详情接口整体等待时间 func queryPlayletDetailExtraData(ctx context.Context, userId int64, playletInfo *playletmod.Playlet, ip, ua string) ([]proto.PlayletChapterInfo, bool, *srvdef.SrvErr) { var wg sync.WaitGroup var mu sync.Mutex var firstErr *srvdef.SrvErr chapterList := make([]proto.PlayletChapterInfo, 0) isLike := false setErr := func(err *srvdef.SrvErr) { if err == nil { return } mu.Lock() if firstErr == nil { firstErr = err } mu.Unlock() } wg.Add(2) go func() { defer wg.Done() data, err := queryPlayletChapterList(ctx, playletInfo.Id, ip, ua, isOriginalPlaylet(playletInfo)) if err != nil { setErr(err) return } mu.Lock() chapterList = data mu.Unlock() }() go func() { defer wg.Done() data, err := queryPlayletIsLike(ctx, userId, playletInfo.Id) if err != nil { setErr(err) return } mu.Lock() isLike = data mu.Unlock() }() wg.Wait() if firstErr != nil { return nil, false, firstErr } return chapterList, isLike, nil } // queryPlayletChapterDetailExtraData 并发查询剧集详情页附加数据,热门短剧只按相同分类查询且不做兜底 func queryPlayletChapterDetailExtraData(ctx context.Context, userId int64, playletInfo *playletmod.Playlet, ip, ua string) (playletmod.PlayletList, []proto.PlayletChapterInfo, bool, *srvdef.SrvErr) { var wg sync.WaitGroup var mu sync.Mutex var firstErr *srvdef.SrvErr hotPlayletList := make(playletmod.PlayletList, 0) chapterList := make([]proto.PlayletChapterInfo, 0) isLike := false setErr := func(err *srvdef.SrvErr) { if err == nil { return } mu.Lock() if firstErr == nil { firstErr = err } mu.Unlock() } wg.Add(3) go func() { defer wg.Done() data, err := queryPlayletChapterDetailHotList(ctx, playletInfo) if err != nil { setErr(err) return } mu.Lock() hotPlayletList = data mu.Unlock() }() go func() { defer wg.Done() data, err := queryPlayletChapterList(ctx, playletInfo.Id, ip, ua, isOriginalPlaylet(playletInfo)) if err != nil { setErr(err) return } mu.Lock() chapterList = data mu.Unlock() }() go func() { defer wg.Done() data, err := queryPlayletIsLike(ctx, userId, playletInfo.Id) if err != nil { setErr(err) return } mu.Lock() isLike = data mu.Unlock() }() wg.Wait() if firstErr != nil { return nil, nil, false, firstErr } return hotPlayletList, chapterList, isLike, nil } // incrementPlayletWatchCount 异步增加短剧浏览、热度和周/月/年窗口观看数。 // 周/月/年窗口按北京时间切:周一 00:00、每月 1 日 00:00、每年 1 月 1 日 00:00。 func incrementPlayletWatchCount(playletInfo *playletmod.Playlet) { if playletInfo == nil { return } now := utils.Now() incDoc := bson.M{ playletmod.FN.Playlet.CountBrowse: 1, playletmod.FN.Playlet.HeatValue: 1, } setDoc := bson.M{ playletmod.FN.Playlet.WatchUpdatedAt: now, } if playletInfo.WatchUpdatedAt.After(utils.BeginOfWeekByTime(now)) { incDoc[playletmod.FN.Playlet.WeekWatchs] = 1 } else { setDoc[playletmod.FN.Playlet.WeekWatchs] = 1 } if playletInfo.WatchUpdatedAt.After(utils.BeginOfMonth(now)) { incDoc[playletmod.FN.Playlet.MonthWatchs] = 1 } else { setDoc[playletmod.FN.Playlet.MonthWatchs] = 1 } if playletInfo.WatchUpdatedAt.After(utils.BeginOfYear(now)) { incDoc[playletmod.FN.Playlet.YearWatchs] = 1 } else { setDoc[playletmod.FN.Playlet.YearWatchs] = 1 } if err := playletmod.UpdateOnePlaylet(context.Background(), bson.M{ playletmod.FN.Playlet.Id: playletInfo.Id, }, bson.M{"$set": setDoc, "$inc": incDoc}); err != nil { log.Warn("incrementPlayletWatchCount failed", log.Any("playletId", playletInfo.Id), log.E(err)) } } // incrementPlayletChapterWatchCount 异步增加短剧剧集浏览数,避免阻塞剧集详情读取 func incrementPlayletChapterWatchCount(chapterId int64) { if chapterId <= 0 { return } if err := playletchaptermod.UpdateOnePlayletChapter(context.Background(), bson.M{ playletchaptermod.FN.PlayletChapter.Id: chapterId, }, bson.M{"$inc": bson.M{ playletchaptermod.FN.PlayletChapter.CountBrowse: 1, }}); err != nil { log.Warn("更新短剧剧集浏览数失败", log.Any("chapterId", chapterId), log.E(err)) } } // queryPlayletChapterDetailHotList 查询剧集详情页热门短剧,按相同分类、累计点击量、更新时间排序,最多返回11条 func queryPlayletChapterDetailHotList(ctx context.Context, playletInfo *playletmod.Playlet) (playletmod.PlayletList, *srvdef.SrvErr) { filter := bson.M{ playletmod.FN.Playlet.Id: bson.M{"$ne": playletInfo.Id}, playletmod.FN.Playlet.Status: playletmod.StatusUp, } if len(playletInfo.CategoryIds) > 0 { filter[playletmod.FN.Playlet.CategoryIds] = bson.M{"$in": playletInfo.CategoryIds} } return findPlayletRecommendPage(ctx, filter, bson.D{ {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, }, 0, playletDetailHotLimit) } // queryPlayletChapterList 查询短剧剧集列表,并按集数升序返回。 // 原创短剧只返回已有水印地址的剧集:首次未处理好的不露出,换水印期间仍可播旧地址。 func queryPlayletChapterList(ctx context.Context, playletId int64, ip, ua string, isOriginal bool) ([]proto.PlayletChapterInfo, *srvdef.SrvErr) { filter := bson.M{ playletchaptermod.FN.PlayletChapter.PlayletId: playletId, } if isOriginal { // 必须含非空白水印地址;空串、空白、缺字段都排除 filter[playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl] = bson.M{"$regex": "\\S"} } list, err := playletchaptermod.FindManyPlayletChapter(ctx, filter, options.Find(). SetProjection(playletChapterProjection()). SetSort(bson.D{ {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, })) if err != nil { return nil, err } data := make([]proto.PlayletChapterInfo, 0, len(list)) for _, item := range list { if item == nil { continue } if isOriginal && strings.TrimSpace(item.WatermarkVideoUrl) == "" { continue } data = append(data, playletChapterToInfo(item, ip, ua, isOriginal)) } return data, nil } // playletChapterProjection 限定详情页剧集字段,避免返回整条剧集文档 func playletChapterProjection() bson.M { return bson.M{ playletchaptermod.FN.PlayletChapter.Id: 1, playletchaptermod.FN.PlayletChapter.PlayletId: 1, playletchaptermod.FN.PlayletChapter.MediaContentId: 1, playletchaptermod.FN.PlayletChapter.CurrentEpisode: 1, playletchaptermod.FN.PlayletChapter.Title: 1, playletchaptermod.FN.PlayletChapter.Md5: 1, playletchaptermod.FN.PlayletChapter.Hash: 1, playletchaptermod.FN.PlayletChapter.Height: 1, playletchaptermod.FN.PlayletChapter.Width: 1, playletchaptermod.FN.PlayletChapter.Ratio: 1, playletchaptermod.FN.PlayletChapter.VideoUrl: 1, playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl: 1, playletchaptermod.FN.PlayletChapter.MediaSize: 1, playletchaptermod.FN.PlayletChapter.MediaTime: 1, playletchaptermod.FN.PlayletChapter.CountBrowse: 1, playletchaptermod.FN.PlayletChapter.CountCollect: 1, playletchaptermod.FN.PlayletChapter.CountLike: 1, playletchaptermod.FN.PlayletChapter.CountComment: 1, playletchaptermod.FN.PlayletChapter.PayType: 1, playletchaptermod.FN.PlayletChapter.SingleCoins: 1, playletchaptermod.FN.PlayletChapter.WatermarkStatus: 1, playletchaptermod.FN.PlayletChapter.CreatedAt: 1, playletchaptermod.FN.PlayletChapter.UpdatedAt: 1, } } // playletChapterToInfo 将短剧剧集模型转换为详情页剧集数据。 // 原创短剧只下水印地址,避免未处理好的剧集回退成原片地址。 func playletChapterToInfo(item *playletchaptermod.PlayletChapter, ip, ua string, isOriginal bool) proto.PlayletChapterInfo { _ = ua videoURL, useNewKey := chapterPlaySource(item, isOriginal) if videoURL != "" { videoURL = videoser.GetPlayletM3U8AuthParam(item.Id, videoURL, ip, useNewKey) } return proto.PlayletChapterInfo{ Id: item.Id, PlayletId: item.PlayletId, MediaContentId: item.MediaContentId, CurrentEpisode: item.CurrentEpisode, Title: item.Title, Md5: item.Md5, Hash: item.Hash, Height: item.Height, Width: item.Width, Ratio: item.Ratio, VideoUrl: videoURL, MediaSize: item.MediaSize, MediaTime: item.MediaTime, CountBrowse: item.CountBrowse, CountCollect: item.CountCollect, CountLike: item.CountLike, CountComment: item.CountComment, PayType: item.PayType, SingleCoins: item.SingleCoins, WatermarkStatus: item.WatermarkStatus, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } } // chapterPlaySource 优先用水印地址。没有水印时,非原创短剧回退原片,并标记使用原片密钥。 func chapterPlaySource(chapter *playletchaptermod.PlayletChapter, isOriginal bool) (string, bool) { if chapter == nil { return "", false } if url := strings.TrimSpace(chapter.WatermarkVideoUrl); url != "" { return url, false } if isOriginal { return "", false } url := strings.TrimSpace(chapter.VideoUrl) return url, url != "" } // isOriginalPlaylet 判断是否原创短剧。 // 后台新增剧没有媒资ID;历史数据可能缺 isOriginal 字段,按无媒资ID兜底。 func isOriginalPlaylet(playlet *playletmod.Playlet) bool { if playlet == nil { return false } return playlet.IsOriginal || playlet.MediaCenterId == 0 } // ensureOriginalChapterWatermarkReady 原创剧集必须已有水印地址才允许播放。 func ensureOriginalChapterWatermarkReady(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) *srvdef.SrvErr { if !isOriginalPlaylet(playlet) || chapter == nil { return nil } if strings.TrimSpace(chapter.WatermarkVideoUrl) != "" { return nil } return srvdef.New(srvdef.ErrNoData, "剧集处理中,请稍后观看", "剧集处理中,请稍后观看") } // queryPlayletIsLike 判断当前登录用户是否已点赞该短剧,未登录直接返回 false func queryPlayletIsLike(ctx context.Context, userId, playletId int64) (bool, *srvdef.SrvErr) { if userId <= 0 { return false, nil } count, err := playletlikemod.CountPlayletLike(ctx, bson.M{ playletlikemod.FN.PlayletLike.UserID: userId, playletlikemod.FN.PlayletLike.PlayletID: playletId, }) if err != nil { return false, err } return count > 0, nil } // playletDetailInfoData 将短剧模型转换为详情页专用响应结构 func playletDetailInfoData(item *playletmod.Playlet, imgCdn string, tagNameMap contenttagmod.ContentTagMap, themeNameMap map[int64]string, isLike, isFavorite bool) proto.PlayletDetailInfo { return proto.PlayletDetailInfo{ PlayletData: playletToAppData(item, imgCdn, tagNameMap, themeNameMap), CountComment: item.CountComment, IsLike: isLike, IsFavorite: isFavorite, } } // queryPlayletIsFavorite 判断当前用户是否已收藏短剧,未登录返回 false func queryPlayletIsFavorite(ctx context.Context, userID, playletID int64) (bool, *srvdef.SrvErr) { if userID <= 0 { return false, nil } count, err := playletfavoritemod.Count(ctx, bson.M{playletfavoritemod.FieldUserID: userID, playletfavoritemod.FieldPlayletID: playletID}) return count > 0, err } // countPlayletTotalLikeRank 统计累计点赞榜可展示短剧数量 func countPlayletTotalLikeRank(ctx context.Context) (int64, *srvdef.SrvErr) { rows := make([]playletRankCount, 0) if err := playletmod.AggregatePlaylet(ctx, &rows, playletTotalLikeRankCountPipeline()); err != nil { return 0, err } if len(rows) == 0 { return 0, nil } return rows[0].Total, nil } // aggregatePlayletTotalLikeRank 分页查询累计点赞榜短剧 func aggregatePlayletTotalLikeRank(ctx context.Context, skip, limit int64) ([]playletRankAggItem, *srvdef.SrvErr) { list := make([]playletRankAggItem, 0) if err := playletmod.AggregatePlaylet(ctx, &list, playletTotalLikeRankPagePipeline(skip, limit)); err != nil { return nil, err } return list, nil } // countPlayletPeriodLikeRank 统计周期新增点赞榜可展示短剧数量 func countPlayletPeriodLikeRank(ctx context.Context, since time.Time) (int64, *srvdef.SrvErr) { rows := make([]playletRankCount, 0) if err := playletlikemod.AggregatePlayletLike(ctx, &rows, playletPeriodLikeRankCountPipeline(since)); err != nil { return 0, err } if len(rows) == 0 { return 0, nil } return rows[0].Total, nil } // aggregatePlayletPeriodLikeRank 分页查询周期新增点赞榜短剧 func aggregatePlayletPeriodLikeRank(ctx context.Context, since time.Time, skip, limit int64) ([]playletRankAggItem, *srvdef.SrvErr) { list := make([]playletRankAggItem, 0) if err := playletlikemod.AggregatePlayletLike(ctx, &list, playletPeriodLikeRankPagePipeline(since, skip, limit)); err != nil { return nil, err } return list, nil } // playletRankPeriodSince 将排行榜周期转换为点赞起始时间 func playletRankPeriodSince(period proto.PlayletRankPeriod) time.Time { now := time.Now() switch period { case proto.PlayletRankPeriodWeek: return now.AddDate(0, 0, -7) case proto.PlayletRankPeriodMonth: return now.AddDate(0, -1, 0) case proto.PlayletRankPeriodYear: return now.AddDate(-1, 0, 0) default: return time.Time{} } } // playletTotalLikeRankCountPipeline 构建累计点赞榜数量统计管道 func playletTotalLikeRankCountPipeline() []bson.M { return append(playletTotalLikeRankBasePipeline(), bson.M{"$count": "total"}) } // playletTotalLikeRankPagePipeline 构建累计点赞榜分页查询管道 func playletTotalLikeRankPagePipeline(skip, limit int64) []bson.M { pipeline := playletTotalLikeRankBasePipeline() pipeline = append(pipeline, bson.M{"$sort": bson.D{ {Key: "rankScore", Value: -1}, {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, }}, bson.M{"$skip": skip}, bson.M{"$limit": limit}, bson.M{"$project": playletRankProjection()}, ) return pipeline } // playletTotalLikeRankBasePipeline 构建累计点赞榜基础过滤管道 func playletTotalLikeRankBasePipeline() []bson.M { return []bson.M{ {"$match": bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp}}, {"$addFields": bson.M{"rankScore": bson.M{"$add": []interface{}{ bson.M{"$ifNull": []interface{}{"$" + playletmod.FN.Playlet.CountLike, 0}}, bson.M{"$ifNull": []interface{}{"$" + playletmod.FN.Playlet.VirtualCountLike, 0}}, }}}}, {"$match": bson.M{"rankScore": bson.M{"$gt": 0}}}, } } // playletPeriodLikeRankCountPipeline 构建周期新增点赞榜数量统计管道 func playletPeriodLikeRankCountPipeline(since time.Time) []bson.M { return append(playletPeriodLikeRankBasePipeline(since), bson.M{"$count": "total"}) } // playletPeriodLikeRankPagePipeline 构建周期新增点赞榜分页查询管道 func playletPeriodLikeRankPagePipeline(since time.Time, skip, limit int64) []bson.M { pipeline := playletPeriodLikeRankBasePipeline(since) pipeline = append(pipeline, bson.M{"$sort": bson.D{ {Key: "rankScore", Value: -1}, {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, }}, bson.M{"$skip": skip}, bson.M{"$limit": limit}, bson.M{"$project": playletRankProjection()}, ) return pipeline } // playletPeriodLikeRankBasePipeline 构建周期新增点赞榜基础过滤管道 func playletPeriodLikeRankBasePipeline(since time.Time) []bson.M { return []bson.M{ {"$match": bson.M{playletlikemod.FN.PlayletLike.CreatedAt: bson.M{"$gte": since}}}, {"$group": bson.M{ "_id": "$" + playletlikemod.FN.PlayletLike.PlayletID, "rankScore": bson.M{"$sum": 1}, }}, {"$lookup": bson.M{ "from": playletmod.TN.Playlet, "localField": "_id", "foreignField": playletmod.FN.Playlet.Id, "as": "playlet", }}, {"$unwind": "$playlet"}, {"$match": bson.M{"playlet." + playletmod.FN.Playlet.Status: playletmod.StatusUp}}, {"$replaceRoot": bson.M{"newRoot": bson.M{"$mergeObjects": []interface{}{"$playlet", bson.M{"rankScore": "$rankScore"}}}}}, } } // playletRankProjection 限定排行榜返回字段,减少分页查询的数据传输体积 func playletRankProjection() bson.M { return bson.M{ playletmod.FN.Playlet.Id: 1, playletmod.FN.Playlet.Status: 1, playletmod.FN.Playlet.IsRecommend: 1, playletmod.FN.Playlet.OnlineAt: 1, playletmod.FN.Playlet.MediaCreatedAt: 1, playletmod.FN.Playlet.MediaUpdatedAt: 1, playletmod.FN.Playlet.Title: 1, playletmod.FN.Playlet.Slug: 1, playletmod.FN.Playlet.Desc: 1, playletmod.FN.Playlet.Anchor: 1, playletmod.FN.Playlet.Cover: 1, playletmod.FN.Playlet.CompressedCover: 1, playletmod.FN.Playlet.Preview: 1, playletmod.FN.Playlet.CategoryIds: 1, playletmod.FN.Playlet.TagIds: 1, playletmod.FN.Playlet.ThemeIds: 1, playletmod.FN.Playlet.ContentType: 1, playletmod.FN.Playlet.PayType: 1, playletmod.FN.Playlet.SingleCoins: 1, playletmod.FN.Playlet.TotalEpisode: 1, playletmod.FN.Playlet.UpdateStatus: 1, playletmod.FN.Playlet.CountBrowse: 1, playletmod.FN.Playlet.VirtualCountBrowse: 1, playletmod.FN.Playlet.HeatValue: 1, playletmod.FN.Playlet.VirtualHeatValue: 1, playletmod.FN.Playlet.CountCollect: 1, playletmod.FN.Playlet.CountLike: 1, playletmod.FN.Playlet.VirtualCountLike: 1, playletmod.FN.Playlet.SeoInfo.Title: 1, playletmod.FN.Playlet.SeoInfo.Description: 1, playletmod.FN.Playlet.SeoInfo.Keyworld: 1, playletmod.FN.Playlet.CreatedAt: 1, playletmod.FN.Playlet.UpdatedAt: 1, "rankScore": 1, } } // playletRankAggItemsToPlayletList 将排行榜聚合数据转换为短剧列表,复用标签题材批量查询 func playletRankAggItemsToPlayletList(list []playletRankAggItem) playletmod.PlayletList { result := make(playletmod.PlayletList, 0, len(list)) for i := range list { result = append(result, &list[i].Playlet) } return result } // playletRankToData 将排行榜聚合结果转换为榜单展示数据 func playletRankToData(item playletRankAggItem, imgCdn string, tagNameMap contenttagmod.ContentTagMap, themeNameMap map[int64]string) proto.PlayletRankData { return proto.PlayletRankData{ PlayletData: playletToAppData(&item.Playlet, imgCdn, tagNameMap, themeNameMap), RankScore: item.RankScore, } } // playletToAppData 将短剧模型转换为 app 短剧基础数据 func playletToAppData(item *playletmod.Playlet, imgCdn string, tagNameMap contenttagmod.ContentTagMap, themeNameMap map[int64]string) proto.PlayletData { if item == nil { return proto.PlayletData{} } return proto.PlayletData{ Id: item.Id, Status: item.Status, IsRecommend: item.IsRecommend, IsNew: isNewPlaylet(item.OnlineAt), IsFeatured: item.IsFeatured, IsOriginal: isOriginalPlaylet(item), ShowOriginal: item.ShowOriginal, OnlineAt: item.OnlineAt, MediaCreatedAt: item.MediaCreatedAt, MediaUpdatedAt: item.MediaUpdatedAt, Title: item.Title, Slug: item.Slug, Desc: item.Desc, Anchor: item.Anchor, Cover: netutil.MergeURL(imgCdn, item.DisplayCover()), Preview: netutil.MergeURL(imgCdn, strings.TrimSpace(item.Preview)), TagIds: item.TagIds, TagNames: playletTagNamesFromMap(item.TagIds, tagNameMap), TagSlugs: playletTagSlugsFromMap(item.TagIds, tagNameMap), ThemeIds: item.ThemeIds, ThemeNames: playletThemeNamesFromMap(item.ThemeIds, themeNameMap), ContentType: item.ContentType, PayType: item.PayType, SingleCoins: item.SingleCoins, TotalEpisode: item.TotalEpisode, UpdateStatus: item.UpdateStatus, CountBrowse: item.DisplayCountBrowse(), HeatValue: item.DisplayHeatValue(), CountCollect: item.CountCollect, CountLike: item.CountLike, VirtualCountLike: item.VirtualCountLike, DisplayCountLike: item.CountLike + item.VirtualCountLike, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, SeoInfo: normalizePlayletSeoValue(item.SeoInfo), } } type playletHistoryRow struct { ChapterID int64 `bson:"chapterId"` Episode int64 `bson:"episode"` UpdatedAt time.Time `bson:"updatedAt"` Playlet playletmod.Playlet `bson:"playlet"` } // ReportPlayletHistory 校验上架短剧和剧集后写入当前用户观看历史 func ReportPlayletHistory(ctx context.Context, userID int64, req *proto.PlayletHistoryReportReq) (*proto.PlayletHistoryReportResp, *srvdef.SrvErr) { if userID <= 0 || req == nil || req.PlayletID <= 0 || req.ChapterID <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "观看历史参数无效", "") } playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: req.PlayletID, playletmod.FN.Playlet.Status: playletmod.StatusUp, }) if err != nil { return nil, err } chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{ playletchaptermod.FN.PlayletChapter.Id: req.ChapterID, playletchaptermod.FN.PlayletChapter.PlayletId: req.PlayletID, }) if err != nil { return nil, err } if !playlet.IsOfficialEpisodeOpen(chapter.CurrentEpisode) { return nil, srvdef.New(srvdef.ErrNoData, "本集暂未在官网开放", "") } if err := ensureOriginalChapterWatermarkReady(playlet, chapter); err != nil { return nil, err } if chapter.CurrentEpisode <= 0 || strings.TrimSpace(chapter.VideoUrl) == "" && strings.TrimSpace(chapter.WatermarkVideoUrl) == "" { return nil, srvdef.New(srvdef.ErrParamError, "剧集不可播放", "") } now := time.Now().UTC() if err := models.TouchWatchHistory(ctx, models.WatchHistory{ UserID: userID, DataType: models.WatchHistoryTypePlaylet, ContentID: req.PlayletID, ChapterID: req.ChapterID, Episode: chapter.CurrentEpisode, CreatedAt: now, UpdatedAt: now, }); err != nil { return nil, err } return &proto.PlayletHistoryReportResp{WatchedAt: now}, nil } // QueryPlayletHistoryList 按最近观看时间倒序分页返回当前用户的上架短剧 func QueryPlayletHistoryList(ctx context.Context, userID int64, req *proto.PlayletHistoryListReq) (*proto.PlayletHistoryListResp, *srvdef.SrvErr) { if userID <= 0 || req == nil { return nil, srvdef.New(srvdef.ErrParamError, "观看历史参数无效", "") } pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, 20, 100) base := []bson.M{ {"$match": bson.M{models.WatchHistoryUserID: userID, models.WatchHistoryDataType: models.WatchHistoryTypePlaylet, models.WatchHistoryUpdatedAt: bson.M{"$gte": time.Now().UTC().AddDate(0, 0, -models.WatchHistoryRetentionDays)}}}, {"$lookup": bson.M{"from": playletmod.TN.Playlet, "localField": models.WatchHistoryContentID, "foreignField": playletmod.FN.Playlet.Id, "as": "playlet"}}, {"$unwind": "$playlet"}, {"$match": bson.M{"playlet." + playletmod.FN.Playlet.Status: playletmod.StatusUp}}, } countRows := make([]struct { Total int64 `bson:"total"` }, 0) if err := models.AggregateWatchHistory(ctx, &countRows, append(append([]bson.M(nil), base...), bson.M{"$count": "total"})); err != nil { return nil, err } total := int64(0) if len(countRows) > 0 { total = countRows[0].Total } resp := &proto.PlayletHistoryListResp{BasePageResp: response.BasePageResp{ TotalCount: total, PageNum: int(pageNum), PageSize: int(pageSize), List: make([]*proto.PlayletHistoryItem, 0), }, Translations: translationser.GetTranslationData(), BottonTranslations: translationser.GetTranslationBottomList()} if total == 0 || pageNum > (total-1)/pageSize+1 { return resp, nil } sortStage := bson.M{"$sort": bson.D{{Key: models.WatchHistoryUpdatedAt, Value: -1}, {Key: "_id", Value: -1}}} pipeline := append([]bson.M{base[0], sortStage}, base[1:]...) pipeline = append(pipeline, bson.M{"$skip": (pageNum - 1) * pageSize}, bson.M{"$limit": pageSize}) rows := make([]playletHistoryRow, 0) if err := models.AggregateWatchHistory(ctx, &rows, pipeline); err != nil { return nil, err } list := make(playletmod.PlayletList, 0, len(rows)) for i := range rows { list = append(list, &rows[i].Playlet) } tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, list) if err != nil { return nil, err } cdn := configser.GetRequestConfig(ctx).ImgCdn items := make([]*proto.PlayletHistoryItem, 0, len(rows)) for _, row := range rows { items = append(items, &proto.PlayletHistoryItem{ PlayletData: playletToAppData(&row.Playlet, cdn, tagNames, themeNames), ChapterID: row.ChapterID, Episode: row.Episode, WatchedAt: row.UpdatedAt, }) } resp.List = items return resp, nil } // ClearPlayletHistory 清空当前用户的短剧观看历史 func ClearPlayletHistory(ctx context.Context, userID int64) (*proto.PlayletHistoryClearResp, *srvdef.SrvErr) { if userID <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "用户无效", "") } count, err := models.DeleteWatchHistory(ctx, userID, models.WatchHistoryTypePlaylet) if err != nil { return nil, err } return &proto.PlayletHistoryClearResp{DeletedCount: count}, nil } // buildPlayletTagThemeNameMaps 批量查询当前页短剧标签和题材名称,避免列表循环内多次访问数据库 func buildPlayletTagThemeNameMaps(ctx context.Context, list playletmod.PlayletList) (contenttagmod.ContentTagMap, map[int64]string, *srvdef.SrvErr) { tagIds := uniquePlayletRelationIds(list, func(item *playletmod.Playlet) []int64 { return item.TagIds }) themeIds := uniquePlayletRelationIds(list, func(item *playletmod.Playlet) []int64 { return item.ThemeIds }) tagNameMap, err := buildPlayletTagNameMap(ctx, tagIds) if err != nil { return nil, nil, err } themeNameMap, err := buildPlayletThemeNameMap(ctx, themeIds) if err != nil { return nil, nil, err } return tagNameMap, themeNameMap, nil } // uniquePlayletRelationIds 按短剧关联字段收集去重后的ID func uniquePlayletRelationIds(list playletmod.PlayletList, values func(item *playletmod.Playlet) []int64) []int64 { ids := make([]int64, 0) seen := make(map[int64]struct{}) for _, item := range list { if item == nil { continue } for _, id := range values(item) { if id <= 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} ids = append(ids, id) } } return ids } // buildPlayletTagNameMap 批量查询当前页短剧标签名称 func buildPlayletTagNameMap(ctx context.Context, tagIds []int64) (contenttagmod.ContentTagMap, *srvdef.SrvErr) { result := make(contenttagmod.ContentTagMap, len(tagIds)) if len(tagIds) == 0 { return result, nil } return contenttagmod.GetMapTags(ctx, tagIds) } // buildPlayletThemeNameMap 批量查询当前页短剧题材名称 func buildPlayletThemeNameMap(ctx context.Context, themeIds []int64) (map[int64]string, *srvdef.SrvErr) { result := make(map[int64]string, len(themeIds)) if len(themeIds) == 0 { return result, nil } themes, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{ playletthememod.FN.PlayletTheme.Id: bson.M{"$in": themeIds}, }, options.Find().SetProjection(bson.M{ playletthememod.FN.PlayletTheme.Id: 1, playletthememod.FN.PlayletTheme.Name: 1, })) if err != nil { return nil, err } for _, theme := range themes { if theme != nil { result[theme.Id] = theme.Name } } return result, nil } // playletTagNamesFromMap 按短剧标签ID原始顺序组装标签名称 func playletTagNamesFromMap(tagIds []int64, tagNameMap contenttagmod.ContentTagMap) []string { result := make([]string, 0, len(tagIds)) for _, tagId := range tagIds { if tag := tagNameMap[tagId]; tag != nil && tag.Name != "" { result = append(result, tag.Name) } } return result } // playletTagSlugsFromMap 按标签名称的顺序组装slug func playletTagSlugsFromMap(tagIds []int64, tagNameMap contenttagmod.ContentTagMap) []string { result := make([]string, 0, len(tagIds)) for _, tagId := range tagIds { if tag := tagNameMap[tagId]; tag != nil && tag.Name != "" { result = append(result, tag.Slug) } } return result } // playletThemeNamesFromMap 按短剧题材ID原始顺序组装题材名称 func playletThemeNamesFromMap(themeIds []int64, themeNameMap map[int64]string) []string { result := make([]string, 0, len(themeIds)) for _, themeId := range themeIds { if name := themeNameMap[themeId]; name != "" { result = append(result, name) } } return result } // isNewPlaylet 判断短剧是否为本周内上架 func isNewPlaylet(onlineAt time.Time) bool { if onlineAt.IsZero() { return false } return onlineAt.After(utils.BeginOfWeekByTime(utils.Now())) } // normalizePlayletSeoValue 规范化 SEO 字段,避免 keywords 为 null func normalizePlayletSeoValue(seo pubdef.SeoValue) pubdef.SeoValue { seo.Title = strings.TrimSpace(seo.Title) seo.Description = strings.TrimSpace(seo.Description) if seo.Keyworld == nil { seo.Keyworld = make([]string, 0) return seo } words := make([]string, 0, len(seo.Keyworld)) for _, word := range seo.Keyworld { word = strings.TrimSpace(word) if word == "" { continue } words = append(words, word) } seo.Keyworld = words return seo } // playletRequestPath 从上下文中提取当前请求路径,避免API层重复组装短剧响应字段 func playletRequestPath(ctx context.Context) string { if ginCtx, ok := ctx.(*gin.Context); ok && ginCtx.Request != nil && ginCtx.Request.URL != nil { return ginCtx.Request.URL.Path } return "" } // emptyPlayletRecommendListPage 构造 app 推荐短剧空分页响应 func emptyPlayletRecommendListPage(pageNum, pageSize int64) *proto.PlayletRecommendListResp { return &proto.PlayletRecommendListResp{BasePageResp: response.BasePageResp{ PageSize: int(pageSize), PageNum: int(pageNum), List: make([]*proto.PlayletRecommendListItem, 0), }} } const ( playletHomeBannerLimit int64 = 5 playletHomeModuleTopicLimit int64 = 4 playletHomeTopicLimit int64 = 3 playletHomeTopicItems int64 = 3 ) // buildHomePlayletResponse 查询并组装短剧首页数据 func buildHomePlayletResponse(ctx context.Context) (*proto.HomePlayletResponse, *srvdef.SrvErr) { cfg := configser.GetRequestConfig(ctx) // 广告和 SEO 使用当前请求的站点配置 seo := pubdef.SeoValue{Title: cfg.WebTitle, Keyworld: cfg.WebKeyworld, Description: cfg.WebDescription} resp := &proto.HomePlayletResponse{ HomeBannerData: make([]*proto.HomeBannerData, 0), Modules: make([]*proto.HomeModuleData, 0), Announcements: make([]*proto.AnnouncementItem, 0), Advitise: configser.GetAdvitise(), SeoInfo: &seo, } // Banner 只取已上架且开启首页展示的短剧,优先展示置顶权重高的内容 banners, err := playletmod.FindManyPlaylet(ctx, bson.M{ playletmod.FN.Playlet.Status: playletmod.StatusUp, playletmod.FN.Playlet.ShowOnBanner: true, }, options.Find().SetSort(bson.D{ {Key: playletmod.FN.Playlet.TopRank, Value: -1}, {Key: playletmod.FN.Playlet.CreatedAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, }).SetLimit(playletHomeBannerLimit)) if err != nil { return nil, err } tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, banners) if err != nil { return nil, err } for _, item := range banners { if item == nil { continue } resp.HomeBannerData = append(resp.HomeBannerData, &proto.HomeBannerData{ PlayletData: playletToAppData(item, cfg.ImgCdn, tagNames, themeNames), ShowOnBanner: item.ShowOnBanner, Banner: netutil.MergeURL(cfg.ImgCdn, item.Banner), }) } // 首页模块仅使用已启用的配置,排序值越大越靠前 configs, err := playlethomeconfigmod.FindManyPlayletHomeConfig(ctx, bson.M{playlethomeconfigmod.FN.PlayletHomeConfig.Enabled: true}, options.Find().SetSort(bson.D{ {Key: playlethomeconfigmod.FN.PlayletHomeConfig.Rank, Value: -1}, {Key: playlethomeconfigmod.FN.PlayletHomeConfig.Id, Value: -1}, })) if err != nil { return nil, err } for _, item := range configs { if item == nil { continue } module, err := homeModule(ctx, item, cfg.ImgCdn) if err != nil { return nil, err } resp.Modules = append(resp.Modules, module) } // 公告只返回启用项,先按排序值再按修改时间倒序排列 announcements, err := announcementmod.FindManyAnnouncement(ctx, bson.M{announcementmod.FN.Announcement.Status: true}, options.Find().SetSort(bson.D{ {Key: announcementmod.FN.Announcement.Sort, Value: -1}, {Key: announcementmod.FN.Announcement.UpdatedAt, Value: -1}, })) if err != nil { return nil, err } for _, item := range announcements { if item != nil { resp.Announcements = append(resp.Announcements, &proto.AnnouncementItem{Id: item.Id, Title: item.Title, Content: item.Content}) } } return resp, nil } // homeModule 按配置的数据类型和模块类型组装首页模块 func homeModule(ctx context.Context, config *playlethomeconfigmod.PlayletHomeConfig, imgCdn string) (*proto.HomeModuleData, *srvdef.SrvErr) { // 未设置条数时使用默认值,并限制最大查询量 limit := config.Limit if limit <= 0 { limit = playlethomeconfigmod.DefaultLimit } if limit > playlethomeconfigmod.MaxLimit { limit = playlethomeconfigmod.MaxLimit } module := &proto.HomeModuleData{ Id: config.Id, Slug: config.Slug, Title: config.Title, Desc: config.Desc, Label: config.Label, Style: config.Style, CardBackgroundImage: netutil.MergeURL(imgCdn, config.CardBackgroundImage), ModuleType: config.ModuleType, DataType: config.DataType, RecommendType: config.RecommendType, CategoryId: config.CategoryId, Rank: config.Rank, List: make([]*proto.PlayletData, 0), TopicList: make([]*proto.HomeTopicData, 0), } // 专题模块按数据类型分别使用视频分类或短剧题材 if config.DataType == playlethomeconfigmod.DataTypeVideo { if config.ModuleType == playlethomeconfigmod.ModuleTypeTopic { topics, err := homeVideoTopics(ctx, config, imgCdn) module.TopicList = topics return module, err } list, err := homeVideos(ctx, config, imgCdn, limit, 0, 0) module.List = list return module, err } if config.ModuleType == playlethomeconfigmod.ModuleTypeTopic { topics, err := homeTopics(ctx, config, imgCdn) module.TopicList = topics return module, err } list, err := homePlaylets(ctx, config, 0, limit) if err != nil { return nil, err } module.List, err = homePlayletData(ctx, list, imgCdn) return module, err } // homePlaylets 查询已上架短剧,按需叠加分类和专题筛选 func homePlaylets(ctx context.Context, config *playlethomeconfigmod.PlayletHomeConfig, themeId, limit int64) (playletmod.PlayletList, *srvdef.SrvErr) { filter, ok := homePlayletFilter(config, themeId) if !ok { return make(playletmod.PlayletList, 0), nil } if config.RecommendType == playlethomeconfigmod.RecommendTypeCategory { return playletmod.FindManyPlayletByCategoryRank(ctx, filter, config.CategoryId, 0, limit, playletmod.CategoryRankSort(), nil) } return findPlayletRecommendPage(ctx, filter, homePlayletSort(config.RecommendType), 0, limit) } // homePlayletSort 将推荐类型映射到短剧排序字段,ID 用于稳定同分排序 func homePlayletSort(recommendType int64) bson.D { var primary string switch recommendType { case playlethomeconfigmod.RecommendTypeUpdated: primary = playletmod.FN.Playlet.UpdatedAt case playlethomeconfigmod.RecommendTypeLike: primary = playletmod.FN.Playlet.CountLike case playlethomeconfigmod.RecommendTypeHeat: primary = playletmod.FN.Playlet.HeatValue case playlethomeconfigmod.RecommendTypeCategory: primary = playletmod.FN.Playlet.RecommendRank default: primary = playletmod.FN.Playlet.CountBrowse } return bson.D{{Key: primary, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}} } // homePlayletData 补齐标签和专题名称并转换为 App 短剧结构 func homePlayletData(ctx context.Context, list playletmod.PlayletList, imgCdn string) ([]*proto.PlayletData, *srvdef.SrvErr) { result := make([]*proto.PlayletData, 0, len(list)) tagNames, themeNames, err := buildPlayletTagThemeNameMaps(ctx, list) if err != nil { return nil, err } for _, item := range list { if item == nil { continue } data := playletToAppData(item, imgCdn, tagNames, themeNames) result = append(result, &data) } return result, nil } // homeVideos 查询审核通过的视频,推荐类型决定排序或分类筛选 func homeVideos(ctx context.Context, config *playlethomeconfigmod.PlayletHomeConfig, imgCdn string, limit, skip, categoryId int64) ([]*proto.HomeVideoData, *srvdef.SrvErr) { result := make([]*proto.HomeVideoData, 0) filter, ok := homeVideoFilter(config, categoryId) if !ok { return result, nil } sortKey := videoinformationmod.FN.VideoInformation.Real.WatchTimes switch config.RecommendType { case playlethomeconfigmod.RecommendTypeUpdated: sortKey = videoinformationmod.FN.VideoInformation.UpdatedAt case playlethomeconfigmod.RecommendTypeLike: sortKey = videoinformationmod.FN.VideoInformation.Real.Likes case playlethomeconfigmod.RecommendTypeHeat: sortKey = videoinformationmod.FN.VideoInformation.HeatValue case playlethomeconfigmod.RecommendTypeCategory: sortKey = videoinformationmod.FN.VideoInformation.Sort } sort := bson.D{{Key: sortKey, Value: -1}} if config.RecommendType == playlethomeconfigmod.RecommendTypeHeat { sort = append(sort, bson.E{Key: videoinformationmod.FN.VideoInformation.Sort, Value: -1}) } sort = append(sort, bson.E{Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}) projection := bson.D{ {Key: videoinformationmod.FN.VideoInformation.Id, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.Title, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.Slug, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.CoverImg, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.Desc, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.Real.Likes, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.Fake.Likes, Value: 1}, } var list videoinformationmod.VideoInformationList var err *srvdef.SrvErr if config.RecommendType == playlethomeconfigmod.RecommendTypeCategory { selectedCategoryId := config.CategoryId if categoryId > 0 { selectedCategoryId = categoryId } project := bson.M{} for _, field := range projection { project[field.Key] = field.Value } list, err = videoinformationmod.FindManyVideoByCategoryRank(ctx, filter, selectedCategoryId, skip, limit, project) } else { list, err = videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find().SetProjection(projection).SetSort(sort).SetSkip(skip).SetLimit(limit)) } if err != nil { return nil, err } // 展示数值为真实统计与后台设置的虚拟统计之和 for _, item := range list { if item == nil { continue } result = append(result, &proto.HomeVideoData{ Id: item.Id, Title: item.Title, Slug: item.Slug, Cover: netutil.MergeURL(imgCdn, item.CoverImg), Desc: item.Desc, Tags: item.Tags, WatchNum: item.Real.WatchTimes + item.Fake.WatchTimes, LikeNum: item.Real.Likes + item.Fake.Likes, }) } return result, nil } // homeVideoTopics 从配置的视频分类中按视频数量选取前四个有内容的专题 func homeVideoTopics(ctx context.Context, config *playlethomeconfigmod.PlayletHomeConfig, imgCdn string) ([]*proto.HomeTopicData, *srvdef.SrvErr) { topics, _, err := homeTopicPage(ctx, config, imgCdn, 0, playletHomeModuleTopicLimit, 0, true, true) return topics, err } // homeTopics 从配置的题材中按上架短剧数量选取前四个专题,每个专题最多取三部短剧 func homeTopics(ctx context.Context, config *playlethomeconfigmod.PlayletHomeConfig, imgCdn string) ([]*proto.HomeTopicData, *srvdef.SrvErr) { topics, _, err := homeTopicPage(ctx, config, imgCdn, 0, playletHomeModuleTopicLimit, 0, true, true) return topics, err } const ( homeConfigPageSize int64 = 20 homeTopicPageSize int64 = 12 homePageMaxSize int64 = 100 homePlayletCacheTTL = 5 * time.Minute ) // HomeConfigList 按首页配置分页查询短剧、视频或专题 func HomeConfigList(ctx context.Context, req *proto.PlayletHomeConfigListReq) (*proto.PlayletHomeConfigListResp, *srvdef.SrvErr) { if req == nil || (req.Id <= 0 && strings.TrimSpace(req.Slug) == "") { return nil, srvdef.New(srvdef.ErrParamError, "首页配置ID或slug不能为空", "") } filter := bson.M{} if slug := strings.TrimSpace(req.Slug); slug != "" { filter[playlethomeconfigmod.FN.PlayletHomeConfig.Slug] = slug } else { filter[playlethomeconfigmod.FN.PlayletHomeConfig.Id] = req.Id } config, err := playlethomeconfigmod.FindOnePlayletHomeConfig(ctx, filter) if err != nil { return nil, err } pageNum, pageSize := normalizeHomePage(req.PageNum, req.PageSize, homeConfigPageSize) resp := &proto.PlayletHomeConfigListResp{ Config: &proto.HomeModuleData{ Id: config.Id, Slug: config.Slug, Title: config.Title, Desc: config.Desc, Label: config.Label, Style: config.Style, CardBackgroundImage: netutil.MergeURL(configser.GetRequestConfig(ctx).ImgCdn, config.CardBackgroundImage), ModuleType: config.ModuleType, DataType: config.DataType, RecommendType: config.RecommendType, CategoryId: config.CategoryId, Rank: config.Rank, List: make([]*proto.PlayletData, 0), TopicList: make([]*proto.HomeTopicData, 0), }, PageNum: int(pageNum), PageSize: int(pageSize), List: make([]*proto.PlayletData, 0), TopicList: make([]*proto.HomeTopicData, 0), Advitise: configser.GetAdvitise(), } if !config.Enabled { return resp, nil } skip := (pageNum - 1) * pageSize imgCdn := configser.GetRequestConfig(ctx).ImgCdn if config.ModuleType == playlethomeconfigmod.ModuleTypeTopic { resp.TopicList, resp.TotalCount, err = homeTopicPage(ctx, config, imgCdn, skip, pageSize, 0, true, true) return resp, err } if config.DataType == playlethomeconfigmod.DataTypeVideo { filter, ok := homeVideoFilter(config, 0) if !ok { resp.List = make([]*proto.HomeVideoData, 0) return resp, nil } resp.TotalCount, err = videoinformationmod.CountVideoInformations(ctx, filter) if err != nil { return nil, err } resp.List, err = homeVideos(ctx, config, imgCdn, pageSize, skip, 0) return resp, err } filter, ok := homePlayletFilter(config, 0) if !ok { return resp, nil } resp.TotalCount, err = playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } var list playletmod.PlayletList if config.RecommendType == playlethomeconfigmod.RecommendTypeCategory { list, err = playletmod.FindManyPlayletByCategoryRank(ctx, filter, config.CategoryId, skip, pageSize, playletmod.CategoryRankSort(), nil) } else { list, err = findPlayletRecommendPage(ctx, filter, homePlayletSort(config.RecommendType), skip, pageSize) } if err != nil { return nil, err } resp.List, err = homePlayletData(ctx, list, imgCdn) return resp, err } // TopicDetail 根据数据类型查询短剧题材或视频分类专题详情 func TopicDetail(ctx context.Context, req *proto.PlayletTopicDetailReq) (*proto.PlayletTopicDetailResp, *srvdef.SrvErr) { if req == nil || (req.Id <= 0 && strings.TrimSpace(req.TopicSlug) == "") { return nil, srvdef.New(srvdef.ErrParamError, "专题ID或slug不能为空", "") } dataType := req.DataType if dataType == 0 { dataType = playlethomeconfigmod.DataTypeAll } if dataType != playlethomeconfigmod.DataTypeAll && dataType != playlethomeconfigmod.DataTypeVideo { return nil, srvdef.New(srvdef.ErrParamError, "数据类型无效", "") } pageNum, pageSize := normalizeHomePage(req.PageNum, req.PageSize, homeTopicPageSize) imgCdn := configser.GetRequestConfig(ctx).ImgCdn resp := &proto.PlayletTopicDetailResp{ DataType: dataType, PageNum: int(pageNum), PageSize: int(pageSize), Covers: make([]string, 0), List: make([]*proto.PlayletData, 0), MoreTopics: make([]*proto.HomeTopicData, 0), Advitise: configser.GetAdvitise(), } skip := (pageNum - 1) * pageSize var err *srvdef.SrvErr if dataType == playlethomeconfigmod.DataTypeVideo { err = fillVideoTopicDetail(ctx, req, resp, imgCdn, skip, pageSize) } else { err = fillPlayletTopicDetail(ctx, req, resp, imgCdn, skip, pageSize) } if err != nil { return nil, err } config := &playlethomeconfigmod.PlayletHomeConfig{DataType: dataType, RecommendType: playlethomeconfigmod.RecommendTypeHeat} resp.MoreTopics, _, err = homeTopicPage(ctx, config, imgCdn, 0, playletHomeTopicLimit, resp.Id, true, false) return resp, err } func fillPlayletTopicDetail(ctx context.Context, req *proto.PlayletTopicDetailReq, resp *proto.PlayletTopicDetailResp, imgCdn string, skip, pageSize int64) *srvdef.SrvErr { filter := bson.M{playletthememod.FN.PlayletTheme.Status: true} if slug := strings.TrimSpace(req.TopicSlug); slug != "" { filter[playletthememod.FN.PlayletTheme.Slug] = slug } else { filter[playletthememod.FN.PlayletTheme.Id] = req.Id } theme, err := playletthememod.FindOnePlayletTheme(ctx, filter) if err != nil { return err } resp.Id, resp.Name, resp.Slug, resp.Rank = theme.Id, theme.Name, theme.Slug, theme.Rank resp.Desc, resp.Icon = theme.SeoInfo.Description, netutil.MergeURL(imgCdn, theme.Icon) seo := theme.SeoInfo resp.SeoInfo = &seo playletFilter := bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp, playletmod.FN.Playlet.ThemeIds: theme.Id} resp.TotalCount, err = playletmod.CountPlaylet(ctx, playletFilter) if err != nil { return err } resp.PlayletCount = resp.TotalCount sortOrder := homeTopicDetailPlayletSort(req.SortType) covers, err := findPlayletRecommendPage(ctx, playletFilter, homeTopicDetailPlayletSort(1), 0, playletHomeTopicItems) if err != nil { return err } for _, item := range covers { if item != nil && item.Cover != "" { resp.Covers = append(resp.Covers, netutil.MergeURL(imgCdn, item.Cover)) } } list, err := findPlayletRecommendPage(ctx, playletFilter, sortOrder, skip, pageSize) if err != nil { return err } resp.List, err = homePlayletData(ctx, list, imgCdn) return err } func fillVideoTopicDetail(ctx context.Context, req *proto.PlayletTopicDetailReq, resp *proto.PlayletTopicDetailResp, imgCdn string, skip, pageSize int64) *srvdef.SrvErr { filter := bson.M{videocategorymod.FN.VideoCategory.Status: true} if slug := strings.TrimSpace(req.TopicSlug); slug != "" { filter[videocategorymod.FN.VideoCategory.Slug] = slug } else { filter[videocategorymod.FN.VideoCategory.Id] = req.Id } category, err := videocategorymod.FindOneVideoCategory(ctx, filter) if err != nil { return err } resp.Id, resp.Name, resp.Slug, resp.Rank = category.Id, category.Name, category.Slug, category.Rank resp.Desc = category.Desc seo := category.SeoInfo resp.SeoInfo = &seo videoFilter := bson.M{videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, videoinformationmod.FN.VideoInformation.CategoryId: category.Id} resp.TotalCount, err = videoinformationmod.CountVideoInformations(ctx, videoFilter) if err != nil { return err } resp.VideoCount = resp.TotalCount config := &playlethomeconfigmod.PlayletHomeConfig{RecommendType: homeTopicDetailVideoRecommendType(req.SortType)} covers, err := homeVideos(ctx, &playlethomeconfigmod.PlayletHomeConfig{RecommendType: playlethomeconfigmod.RecommendTypeHeat}, imgCdn, playletHomeTopicItems, 0, category.Id) if err != nil { return err } for _, item := range covers { if item != nil && item.Cover != "" { resp.Covers = append(resp.Covers, item.Cover) } } resp.List, err = homeVideos(ctx, config, imgCdn, pageSize, skip, category.Id) return err } func homeTopicDetailPlayletSort(sortType int64) bson.D { switch sortType { case 2: return bson.D{{Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}} case 3: return bson.D{{Key: playletmod.FN.Playlet.HeatValue, Value: -1}, {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}} default: return bson.D{{Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}} } } func homeTopicDetailVideoRecommendType(sortType int64) int64 { switch sortType { case 2: return playlethomeconfigmod.RecommendTypeUpdated case 3: return playlethomeconfigmod.RecommendTypeBrowse default: return playlethomeconfigmod.RecommendTypeCategory } } func normalizeHomePage(pageNum, pageSize, defaultSize int64) (int64, int64) { if pageNum <= 0 { pageNum = 1 } if pageSize <= 0 { pageSize = defaultSize } if pageSize > homePageMaxSize { pageSize = homePageMaxSize } return pageNum, pageSize } func homePlayletFilter(config *playlethomeconfigmod.PlayletHomeConfig, themeId int64) (bson.M, bool) { filter := bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp} if config.RecommendType == playlethomeconfigmod.RecommendTypeCategory { if config.CategoryId <= 0 { return nil, false } filter[playletmod.FN.Playlet.CategoryIds] = config.CategoryId } if themeId > 0 { filter[playletmod.FN.Playlet.ThemeIds] = themeId } return filter, true } func homeVideoFilter(config *playlethomeconfigmod.PlayletHomeConfig, categoryId int64) (bson.M, bool) { filter := bson.M{videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK} if categoryId > 0 { filter[videoinformationmod.FN.VideoInformation.CategoryId] = categoryId } else if config.RecommendType == playlethomeconfigmod.RecommendTypeCategory { if config.CategoryId <= 0 { return nil, false } filter[videoinformationmod.FN.VideoInformation.CategoryId] = config.CategoryId } return filter, true } // homeTopicPage 按专题排序分页,可分别限定有上架内容和首页配置关联的专题 func homeTopicPage(ctx context.Context, config *playlethomeconfigmod.PlayletHomeConfig, imgCdn string, skip, limit, excludeId int64, onlyWithContent, onlyConfigured bool) ([]*proto.HomeTopicData, int64, *srvdef.SrvErr) { type topicStat struct { id, rank, count int64 name, slug, icon string seo pubdef.SeoValue } stats := make([]topicStat, 0) selectedTopics := make(map[int64]struct{}, len(config.TopicIds)) if onlyConfigured { for _, id := range config.TopicIds { selectedTopics[id] = struct{}{} } if len(selectedTopics) == 0 { return make([]*proto.HomeTopicData, 0), 0, nil } } if config.DataType == playlethomeconfigmod.DataTypeVideo { filter := bson.M{videocategorymod.FN.VideoCategory.Status: true} if onlyConfigured { filter[videocategorymod.FN.VideoCategory.Id] = bson.M{"$in": config.TopicIds} } if config.RecommendType == playlethomeconfigmod.RecommendTypeCategory { if config.CategoryId <= 0 { return make([]*proto.HomeTopicData, 0), 0, nil } filter[videocategorymod.FN.VideoCategory.Id] = config.CategoryId } categories, err := videocategorymod.FindManyVideoCategorys(ctx, filter) if err != nil { return nil, 0, err } for _, category := range categories { if category == nil || category.Id == excludeId { continue } if onlyConfigured { if _, ok := selectedTopics[category.Id]; !ok { continue } } count, err := videoinformationmod.CountVideoInformations(ctx, bson.M{ videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, videoinformationmod.FN.VideoInformation.CategoryId: category.Id, }) if err != nil { return nil, 0, err } if onlyWithContent && count == 0 { continue } stats = append(stats, topicStat{id: category.Id, rank: category.Rank, count: count, name: category.Name, slug: category.Slug, seo: category.SeoInfo}) } } else { filter := bson.M{playletthememod.FN.PlayletTheme.Status: true} if onlyConfigured { filter[playletthememod.FN.PlayletTheme.Id] = bson.M{"$in": config.TopicIds} } themes, err := playletthememod.FindManyPlayletTheme(ctx, filter) if err != nil { return nil, 0, err } for _, theme := range themes { if theme == nil || theme.Id == excludeId { continue } if onlyConfigured { if _, ok := selectedTopics[theme.Id]; !ok { continue } } filter, ok := homePlayletFilter(config, theme.Id) if !ok { continue } count, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, 0, err } if onlyWithContent && count == 0 { continue } stats = append(stats, topicStat{id: theme.Id, rank: theme.Rank, count: count, name: theme.Name, slug: theme.Slug, icon: theme.Icon, seo: theme.SeoInfo}) } } sort.Slice(stats, func(i, j int) bool { if stats[i].count != stats[j].count { return stats[i].count > stats[j].count } if stats[i].rank != stats[j].rank { return stats[i].rank > stats[j].rank } return stats[i].id > stats[j].id }) total := int64(len(stats)) result := make([]*proto.HomeTopicData, 0) if skip >= total || limit <= 0 { return result, total, nil } end := skip + limit if end > total { end = total } if onlyWithContent { end = total // 被排除的内容可能让专题变空,继续查后续专题补足展示数量 } for _, stat := range stats[int(skip):int(end)] { if onlyWithContent && int64(len(result)) >= limit { break } item := &proto.HomeTopicData{Id: stat.id, Name: stat.name, Slug: stat.slug, Icon: netutil.MergeURL(imgCdn, stat.icon), Rank: stat.rank, SeoInfo: stat.seo} if config.DataType == playlethomeconfigmod.DataTypeVideo { item.VideoCount = stat.count list, err := homeVideos(ctx, config, imgCdn, playletHomeTopicItems, 0, stat.id) if err != nil { return nil, 0, err } item.List = list if onlyWithContent && len(list) == 0 { continue } } else { item.PlayletCount = stat.count list, err := homePlaylets(ctx, config, stat.id, playletHomeTopicItems) if err != nil { return nil, 0, err } if onlyWithContent && len(list) == 0 { continue } item.List, err = homePlayletData(ctx, list, imgCdn) if err != nil { return nil, 0, err } } result = append(result, item) } return result, total, nil } // Home 优先返回 Redis 缓存,未命中或缓存不可用时查询数据库 func Home(ctx context.Context) (*proto.HomePlayletResponse, *srvdef.SrvErr) { key := homePlayletCacheKey(configser.GetRequestConfig(ctx).ImgCdn) if resp := getHomePlayletCache(key); resp != nil { return resp, nil } resp, err := buildHomePlayletResponse(ctx) if err != nil { return nil, err } setHomePlayletCache(key, resp) return resp, nil } // homePlayletCacheKey 包含缓存版本和图片域名,后台变更或域名变更后使用新键 func homePlayletCacheKey(imgCdn string) string { if redis.Client() == nil { return "" } version := "0" value, err := redis.Get(redisconst.AppPlayletHomeCacheVersionKey()) if err != nil { log.Warn("读取短剧首页缓存版本失败", log.E(err)) return "" } if value != nil && strings.TrimSpace(*value) != "" { version = strings.TrimSpace(*value) } cdnHash := sha256.Sum256([]byte(imgCdn)) return fmt.Sprintf("app:playlet:home:data:%d:v7:%s:%x", pubdef.AppId, version, cdnHash[:8]) } // getHomePlayletCache 读取并反序列化完整首页响应 func getHomePlayletCache(key string) *proto.HomePlayletResponse { if key == "" { return nil } value, err := redis.Get(key) if err != nil { log.Warn("读取短剧首页缓存失败", log.E(err)) return nil } if value == nil || *value == "" { return nil } var resp proto.HomePlayletResponse if err := json.Unmarshal([]byte(*value), &resp); err != nil { log.Warn("解析短剧首页缓存失败", log.E(err)) return nil } return &resp } // setHomePlayletCache 缓存完整首页响应五分钟 func setHomePlayletCache(key string, resp *proto.HomePlayletResponse) { if key == "" || resp == nil { return } data, err := json.Marshal(resp) if err != nil { log.Warn("序列化短剧首页缓存失败", log.E(err)) return } if err := redis.Set(key, string(data), homePlayletCacheTTL); err != nil { log.Warn("写入短剧首页缓存失败", log.E(err)) } }